From 87ed3682ec1482d88b6a377c0231ee3878ad1d48 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Sat, 7 Mar 2026 13:27:03 +0000 Subject: [PATCH 1/2] feat: add table layout and rendering API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PDF.drawTable() for creating tables with automatic pagination, repeated headers/footers, flexible column widths (fixed/auto/star), text wrapping with break-word support, and a style cascade system. Internal architecture splits into normalize → measure → layout → render pipeline under src/tables/, keeping layout computation pure and testable independent of PDF drawing. --- .agents/plans/clever-ivory-wind-table-api.md | 553 +++++++++++++++++++ src/api/pdf.ts | 60 ++ src/index.ts | 24 + src/tables/index.ts | 0 src/tables/layout.test.ts | 182 ++++++ src/tables/layout.ts | 275 +++++++++ src/tables/measure.test.ts | 172 ++++++ src/tables/measure.ts | 380 +++++++++++++ src/tables/normalize.test.ts | 118 ++++ src/tables/normalize.ts | 160 ++++++ src/tables/render.ts | 220 ++++++++ src/tables/style.ts | 139 +++++ src/tables/table.integration.test.ts | 448 +++++++++++++++ src/tables/types.ts | 159 ++++++ 14 files changed, 2890 insertions(+) create mode 100644 .agents/plans/clever-ivory-wind-table-api.md create mode 100644 src/tables/index.ts create mode 100644 src/tables/layout.test.ts create mode 100644 src/tables/layout.ts create mode 100644 src/tables/measure.test.ts create mode 100644 src/tables/measure.ts create mode 100644 src/tables/normalize.test.ts create mode 100644 src/tables/normalize.ts create mode 100644 src/tables/render.ts create mode 100644 src/tables/style.ts create mode 100644 src/tables/table.integration.test.ts create mode 100644 src/tables/types.ts diff --git a/.agents/plans/clever-ivory-wind-table-api.md b/.agents/plans/clever-ivory-wind-table-api.md new file mode 100644 index 0000000..67f187d --- /dev/null +++ b/.agents/plans/clever-ivory-wind-table-api.md @@ -0,0 +1,553 @@ +--- +date: 2026-03-06 +title: Practical Table API +--- + +## Problem + +Tables are the highest-friction missing creation feature in libpdf. + +Today users can draw text, lines, rectangles, images, and arbitrary operators, but invoice-grade tables still require manual x/y math, manual text measurement, manual row height calculation, and manual page breaking. That is exactly the kind of work users expect the library to absorb. + +The current drawing stack is strong enough to support tables: + +- `PDFPage.drawText()` already supports wrapped multiline text. +- `src/drawing/text-layout.ts` already measures and wraps text. +- `PDFPage.drawRectangle()` and `PDFPage.drawLine()` already cover the border/background primitives a table needs. +- `PDF.addPage()` already gives us the document-level control required for autopagination. + +What is missing is a table-specific layout and pagination engine. + +## Goals + +1. Add a practical table API for generated PDFs, especially invoices, item lists, statements, and tabular reports. +2. Make the first version good enough for real work: repeated headers, optional repeated footers, flexible columns, wrapped text, deterministic pagination, and useful styling. +3. Keep the public API high-level and task-focused, consistent with `PDF`, `PDFPage`, and the rest of the high-level surface. +4. Keep the implementation architecture aligned with the existing layered design: a high-level user API backed by internal layout and drawing primitives. +5. Preserve future room for richer layout features without forcing v1 to become a full document-flow engine. + +## Non-Goals + +These should not block v1: + +- A general document layout engine for arbitrary flowing elements. +- HTML or CSS table import. +- Rich text, images, or nested tables inside cells. +- `rowSpan` and `colSpan` in v1. +- Streaming/flush APIs for very large tables. +- A full iText-style `Document` flow model. +- Auto-detection of semantic tables from existing page content. + +## Research Summary + +This plan is based on direct source and docs research, including cloned reference code under `/tmp/libpdf-table-research-sparse-20260306`. + +### iText 7 + +What to copy: + +- Tables are true layout elements, not just drawing helpers. +- Header and footer sections are explicit (`addHeaderCell`, `addFooterCell`) instead of being inferred from row indices. +- Repeating header/footer behavior is a first-class concern. +- Row splitting is robust and considered normal behavior. +- `setSkipFirstHeader(true)` and `setSkipLastFooter(true)` support continued-table behavior. + +What not to copy in v1: + +- The full document-flow engine and large-table flushing model. +- The expectation that tables participate in arbitrary block layout across many unrelated layout elements. + +### jsPDF AutoTable + +What to copy: + +- Declarative `head` / `body` / `foot` sections are practical. +- The style cascade is highly useful in real applications. +- Width modes like fixed, auto-sized, and fill-the-rest are practical. +- Table-focused pagination rules are good enough without requiring a whole document model. + +What to avoid: + +- Mutation hooks that can change metrics during rendering. Those risk desynchronizing measurement from drawing. +- The known source-level compromise where colspan cells do not properly contribute to width calculation. + +### pdfmake + +What to copy: + +- Star/flex column widths are a simple and powerful idea. +- Repeated header rows and keep-with-header concepts are useful for reports and invoices. +- Repeatable fragments are a good internal model for page-break handling. + +What not to copy: + +- Placeholder-cell ergonomics for spans. +- Extending the table API into a full document-definition DSL in v1. + +### ReportLab + +What to copy: + +- `repeatRows` and `NOSPLIT` show the value of simple, explicit pagination controls. + +What not to copy: + +- Style-command-based spans and layout directives. They are powerful but awkward for a TypeScript-first API. + +## Proposed Public API + +V1 should make the table a document-owned operation, not a page-owned one. + +```typescript +const pdf = PDF.create(); +const startPage = pdf.addPage({ size: "letter" }); + +const result = pdf.drawTable( + startPage, + { + columns: [ + { key: "item", width: 72 }, + { key: "description", width: "*" }, + { key: "qty", width: 40, align: "right" }, + { key: "price", width: 72, align: "right" }, + { key: "total", width: 72, align: "right" }, + ], + head: [["Item", "Description", "Qty", "Price", "Total"]], + body: lineItems.map(item => [ + item.sku, + item.description, + String(item.quantity), + money(item.unitPrice), + money(item.total), + ]), + foot: [ + ["", "", "", "Subtotal", money(subtotal)], + ["", "", "", "Tax", money(tax)], + ["", "", "", "Total", money(total)], + ], + }, + { + bounds: { x: 48, y: 72, width: 516, height: 640 }, + headRepeat: "everyPage", + footRepeat: "lastPage", + style: { + fontSize: 10, + lineHeight: 14, + padding: 6, + borderWidth: 0.5, + }, + headStyle: { + font: "Helvetica-Bold", + fillColor: grayscale(0.9), + }, + alternateRowStyle: { + fillColor: grayscale(0.97), + }, + }, +); + +result.lastPage.drawText("Thank you for your business", { + x: 48, + y: result.cursorY - 24, + size: 10, +}); +``` + +### Why `PDF.drawTable(...)` Instead of `PDFPage.drawTable(...)` + +Autopagination requires document-level control. + +A table that starts on one page may need to create one or more following pages. `PDF.addPage()` already owns that responsibility. `PDFPage` is the right rendering target, but it is not the right owner for a multi-page operation unless `PDFPage` later gains a safe back-reference to its parent `PDF`. + +That means: + +- `PDF.drawTable(startPage, ...)` is the primary v1 API. +- A `PDFPage.drawTable(...)` convenience can be added later if `PDFPage` gains a safe parent-document reference. + +## Proposed API Surface + +The exact names can still be tuned, but the shape should be close to this. + +```typescript +export type TableWidth = number | "auto" | "*" | { star: number }; +export type TableRepeat = "none" | "firstPage" | "everyPage" | "lastPage"; +export type TableOverflow = "wrap" | "ellipsis" | "clip"; +export type TableOverflowWrap = "word" | "break-word"; +export type TableHAlign = "left" | "center" | "right"; +export type TableVAlign = "top" | "middle" | "bottom"; + +export interface TableColumn { + key: string; + width?: TableWidth; + minWidth?: number; + maxWidth?: number; + align?: TableHAlign; + style?: Partial; +} + +export interface TableCell { + text: string; + align?: TableHAlign; + valign?: TableVAlign; + overflow?: TableOverflow; + overflowWrap?: TableOverflowWrap; + style?: Partial; +} + +export interface TableRowDefinition { + cells: readonly (string | TableCell)[]; + keepTogether?: boolean; + style?: Partial; +} + +export type TableRow = readonly (string | TableCell)[] | TableRowDefinition; + +export interface TableDefinition { + columns: readonly TableColumn[]; + head?: readonly TableRow[]; + body: readonly TableRow[]; + foot?: readonly TableRow[]; +} + +export interface TableCellStyle { + font: FontInput; + fontSize: number; + lineHeight: number; + textColor: Color; + fillColor?: Color; + padding: number | { top?: number; right?: number; bottom?: number; left?: number }; + borderColor?: Color; + borderWidth?: number | { top?: number; right?: number; bottom?: number; left?: number }; + align: TableHAlign; + valign: TableVAlign; + overflow: TableOverflow; + overflowWrap: TableOverflowWrap; +} + +export interface DrawTableOptions { + bounds: Rectangle; + headRepeat?: Extract; + footRepeat?: Extract; + style?: Partial; + headStyle?: Partial; + bodyStyle?: Partial; + footStyle?: Partial; + alternateRowStyle?: Partial; + columnStyles?: Record>; + keepWithNextRows?: number; +} + +export interface DrawTableResult { + lastPage: PDFPage; + usedPages: PDFPage[]; + contentBox: Rectangle; + cursorY: number; + rowCountDrawn: number; +} +``` + +Important design choices: + +- `head`, `body`, and `foot` are explicit sections. +- Row-level metadata is allowed through `TableRowDefinition`, but simple arrays remain the common shorthand. +- Cells are text-only in v1. +- No `any`-typed cell payloads. + +## High-Level Architecture + +This feature should live in the high-level API layer, but it should be implemented as two internal sublayers. + +### 1. Public High-Level API + +- `PDF.drawTable(...)` is the only new public entry point in v1. +- The public surface stays task-focused and ergonomic. +- Tables remain a high-level feature built on top of existing drawing APIs. + +### 2. Internal Table Layout Layer + +Create an internal `src/tables/` module that does not directly talk to `PDFPage` while computing layout. + +Suggested internal modules: + +- `src/tables/types.ts` - normalized internal table models +- `src/tables/normalize.ts` - validate shorthand input and section structure +- `src/tables/style.ts` - resolve style cascade +- `src/tables/measure.ts` - text measurement and width constraints +- `src/tables/layout.ts` - column sizing, row sizing, pagination, page fragments +- `src/tables/render.ts` - render precomputed fragments to `PDFPage` + +This keeps the engine testable and prevents drawing concerns from leaking into layout decisions. + +## Layout Model + +### Coordinate Strategy + +The public API should continue using native PDF coordinates. + +That means `bounds` is expressed as: + +- `x`, `y` = bottom-left of the allowed drawing area +- `width`, `height` = usable table region + +Internally, the table engine should still think in a top-down flow cursor because tables naturally grow downward. The renderer can translate that into native PDF coordinates at the moment of drawing. + +### Column Widths + +V1 should support three practical width modes: + +- `number` - fixed width in points +- `"auto"` - content-driven preferred width with min/max clamping +- `"*"` or `{ star: n }` - proportional share of remaining width + +Resolution order: + +1. Normalize the table and validate column count. +2. Measure each cell's preferred width and minimum readable width. +3. Lock fixed-width columns. +4. Estimate `auto` widths from content. +5. Distribute remaining width across star columns. +6. If total width still exceeds available width, shrink `auto` and star columns down to min widths. +7. If the table still cannot fit, throw a typed layout error with diagnostics. + +V1 should not attempt full CSS-style table layout. + +### Row Heights + +Row height should be the maximum resolved cell height in that row after: + +- applying effective width +- wrapping text +- applying padding +- applying explicit line height + +### Text Wrapping + +The current `layoutText()` helper only preserves long words; it does not break them. + +That is acceptable for generic paragraph drawing, but it is not enough for practical tables because SKUs, IDs, codes, URLs, and account numbers often exceed column width. + +V1 table layout therefore needs an explicit cell overflow policy: + +- `overflow: "wrap" | "ellipsis" | "clip"` +- `overflowWrap: "word" | "break-word"` + +Default recommendation for body cells: + +- `overflow: "wrap"` +- `overflowWrap: "break-word"` + +This should be implemented in the table engine without changing the existing `PDFPage.drawText()` contract. + +## Pagination Rules + +V1 should support deterministic row-based pagination. + +### Header and Footer Behavior + +- `headRepeat: "everyPage"` should be the default when a head section exists. +- `footRepeat: "none"` should be the default. +- `footRepeat: "lastPage"` should support invoice totals naturally. +- `footRepeat: "everyPage"` should support carried subtotals or repeated summaries. + +### Row Breaking + +Each body row should follow these rules: + +1. If the entire row fits, draw it on the current page. +2. If it does not fit and the row is marked `keepTogether`, start a new page. +3. If it still cannot fit on a fresh page, throw a typed error that identifies the row. +4. If it is not `keepTogether`, allow splitting text lines across pages. + +V1 row splitting should only support text-only cells. That keeps the problem tractable and matches the declared v1 scope. + +### Keep-With-Header Behavior + +A small report often looks broken if the header is repeated and only a single body row fits beneath it. V1 should therefore include a simple `keepWithNextRows` control at the table level so callers can require a small minimum number of body rows after a repeated header. + +## Styling Model + +The style system should borrow the useful parts of AutoTable while staying deterministic. + +Recommended cascade order: + +1. library defaults +2. table-wide `style` +3. section style (`headStyle`, `bodyStyle`, `footStyle`) +4. `alternateRowStyle` +5. column style +6. row style +7. cell style + +This should all resolve before rendering starts. + +What v1 should support: + +- font and font size +- line height +- text color and fill color +- padding +- border color and border width +- horizontal and vertical alignment +- text overflow behavior + +What v1 should defer: + +- border dash patterns +- per-side corner styling +- gradients and patterns in table cells +- mutation hooks that can rewrite styles during draw + +## Rendering Model + +The renderer should stay thin and build on the existing drawing API. + +For each page fragment: + +- create or select the target page +- draw cell backgrounds +- draw cell borders +- draw cell text +- update cursor state + +The renderer should rely on: + +- `PDFPage.drawRectangle()` for backgrounds and simple cell boxes +- `PDFPage.drawLine()` for precise borders when side-specific borders are needed +- `PDFPage.drawText()` only where it matches the already-computed layout, or direct text operators if needed for exact line placement + +The important constraint is that measurement must already be complete before this stage. + +## Why a Pure Layout Pass Matters + +Without a pure layout pass, the implementation will eventually drift into repeated measurement while drawing, hidden pagination side effects, and hooks that change state after the engine has already committed to page breaks. + +A pure layout pass gives us: + +- deterministic tests for pagination +- easier debugging for row height and width issues +- a future path toward exposing layout information publicly +- a clean separation between table decisions and PDF drawing details + +## Phased Delivery + +### Phase 1: Core Invoice-Grade Tables + +Ship the smallest version that is still practical: + +- `PDF.drawTable(...)` +- explicit `head`, `body`, `foot` +- fixed, auto, and star column widths +- text-only cells +- wrapping and break-word behavior +- repeated headers +- optional repeated or last-page-only footers +- row `keepTogether` +- deterministic `DrawTableResult` +- integration tests with visual output + +### Phase 2: Controlled Extension Points + +Only after the layout model is stable: + +- read-only page hooks for drawing repeated content around the table +- optional post-render hooks that cannot change measurement +- a convenience `PDFPage.drawTable(...)` wrapper if `PDFPage` gets a safe back-reference to `PDF` + +### Phase 3: Richer Table Features + +Once core pagination is stable and well tested: + +- `rowSpan` and `colSpan` +- rich cell content +- nested tables +- public `layoutTable(...)` export for custom renderers +- large-table streaming and flush semantics + +## Test Plan + +This feature needs both unit-level layout tests and integration-level PDF output tests. + +### Unit Tests + +- column width resolution across fixed, auto, and star columns +- row height resolution with padding and line height +- break-word behavior for long tokens +- overflow behavior for wrap, ellipsis, and clip +- page-break decisions with repeated headers and optional footers +- `keepTogether` behavior on rows +- typed failures when a row cannot fit in a fresh page + +### Integration Tests + +Generate PDFs in `test-output/` that cover: + +- simple table on one page +- invoice table that breaks across pages +- repeated header rows +- last-page-only totals footer +- alternating row background styles +- right-aligned numeric columns +- long SKU / code / URL cells with break-word behavior +- narrow columns with auto + star widths together + +### Visual Verification + +Like the existing drawing integration tests, these outputs should be easy to open in Preview, Chrome, and Acrobat. + +## Risks and Mitigations + +### Risk: Wrong public owner for autopagination + +If the first API is `PDFPage.drawTable(...)`, the implementation will either need awkward hidden document access or it will quietly fail to own page creation cleanly. + +Mitigation: + +- start with `PDF.drawTable(startPage, ...)` + +### Risk: Reusing generic text layout without table-specific overflow behavior + +If v1 depends entirely on the current `layoutText()` behavior, long invoice tokens will overflow or force unusable columns. + +Mitigation: + +- add table-specific overflow and break-word policy in `src/tables/measure.ts` + +### Risk: Overreaching into full document layout + +Trying to solve tables, paragraphs, floating blocks, and arbitrary page-flow together will delay the feature and increase design churn. + +Mitigation: + +- keep v1 narrowly scoped to tables only + +### Risk: AutoTable-style hooks altering layout after measurement + +If hooks can mutate cell content or styles after page breaks are chosen, bugs will be subtle and hard to test. + +Mitigation: + +- resolve styles before layout +- keep any future hooks read-only during render + +## Success Criteria + +This plan is successful when libpdf can support the following without manual page math by the caller: + +1. An invoice line-item table that breaks across pages cleanly. +2. A repeated header row on every continuation page. +3. A subtotal/total footer that appears only on the last page or every page, depending on configuration. +4. Numeric columns aligned cleanly to the right. +5. Long descriptions and long codes wrapped predictably. +6. A result object that lets the caller continue drawing after the table. + +## Open Questions + +None that should block the plan. + +The defaults that should be used unless implementation evidence proves otherwise are: + +- primary API: `PDF.drawTable(startPage, ...)` +- header repeat default: `everyPage` when `head` exists +- footer repeat default: `none` +- default cell overflow: `wrap` +- default word policy for table cells: `break-word` diff --git a/src/api/pdf.ts b/src/api/pdf.ts index db8f12a..2707131 100644 --- a/src/api/pdf.ts +++ b/src/api/pdf.ts @@ -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"; @@ -2067,6 +2071,62 @@ export class PDF { // Image Embedding // ───────────────────────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────────── + // 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 + // ───────────────────────────────────────────────────────────────────────────── + /** * Embed an image (JPEG or PNG) into the document. * diff --git a/src/index.ts b/src/index.ts index 497d5ca..532b26d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/tables/index.ts b/src/tables/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/tables/layout.test.ts b/src/tables/layout.test.ts new file mode 100644 index 0000000..5a6b9bf --- /dev/null +++ b/src/tables/layout.test.ts @@ -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["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); + }); +}); diff --git a/src/tables/layout.ts b/src/tables/layout.ts new file mode 100644 index 0000000..77d386a --- /dev/null +++ b/src/tables/layout.ts @@ -0,0 +1,275 @@ +import { + measureRow, + resolveColumnWidths, + TableLayoutError, + TableRowOverflowError, +} from "./measure"; +import type { DrawTableOptions, MeasuredRow, NormalizedTable, PageFragment } from "./types"; + +export interface TableLayoutResult { + fragments: PageFragment[]; + columnWidths: number[]; +} + +const EPSILON = 0.01; + +export function layoutTable(table: NormalizedTable, options: DrawTableOptions): TableLayoutResult { + const { bounds } = options; + const columnWidths = resolveColumnWidths(table, bounds.width); + + // Measure all rows + const headRows = table.head.map(r => measureRow(r, columnWidths)); + const bodyRows = table.body.map(r => measureRow(r, columnWidths)); + const footRows = table.foot.map(r => measureRow(r, columnWidths)); + + const headRepeat = options.headRepeat ?? (table.head.length > 0 ? "everyPage" : "none"); + const footRepeat = options.footRepeat ?? "none"; + + const fragments: PageFragment[] = []; + const remainingBodyRows = [...bodyRows]; + const hasBodyRows = remainingBodyRows.length > 0; + + while (remainingBodyRows.length > 0 || (!hasBodyRows && fragments.length === 0)) { + const isFirstPage = fragments.length === 0; + const pageHead = shouldShowHead(headRepeat, isFirstPage) ? headRows : []; + const repeatedFoot = footRepeat === "everyPage" ? footRows : []; + const currentCapacity = getBodyCapacity(bounds.height, pageHead, repeatedFoot); + + let pageBodyRows = fillPageRows(remainingBodyRows, currentCapacity); + + if (footRepeat === "lastPage" && remainingBodyRows.length === 0) { + const lastPageCapacity = getBodyCapacity(bounds.height, pageHead, footRows); + + while (sumRowHeights(pageBodyRows) > lastPageCapacity + EPSILON) { + if (pageBodyRows.length === 0) { + break; + } + remainingBodyRows.unshift(pageBodyRows.pop()!); + } + + if (pageBodyRows.length === 0 && remainingBodyRows.length > 0) { + pageBodyRows = fillPageRows(remainingBodyRows, lastPageCapacity); + } + } + + const isLastPage = remainingBodyRows.length === 0; + const pageFoot = shouldShowFoot(footRepeat, isLastPage) ? footRows : []; + + if (pageBodyRows.length === 0 && remainingBodyRows.length > 0) { + const row = remainingBodyRows[0]; + const continuationHead = shouldShowHead(headRepeat, false) ? headRows : []; + const continuationCapacity = getBodyCapacity(bounds.height, continuationHead, repeatedFoot); + + if ( + isFirstPage && + pageHead.length > 0 && + continuationCapacity > currentCapacity + EPSILON && + row.rowHeight <= continuationCapacity + EPSILON + ) { + fragments.push({ + rows: [], + headRows: pageHead, + footRows: repeatedFoot, + isFirstPage, + isLastPage: false, + }); + continue; + } + + throw new TableRowOverflowError(row.bodyIndex, row.rowHeight, currentCapacity); + } + + if (pageHead.length === 0 && pageBodyRows.length === 0 && pageFoot.length === 0) { + continue; + } + + fragments.push({ + rows: pageBodyRows, + headRows: pageHead, + footRows: pageFoot, + isFirstPage, + isLastPage, + }); + } + + // Edge case: empty body with head/foot only + if (fragments.length === 0) { + const pageHead = shouldShowHead(headRepeat, true) ? headRows : []; + const pageFoot = shouldShowFoot(footRepeat, true) ? footRows : []; + getBodyCapacity(bounds.height, pageHead, pageFoot); + + fragments.push({ + rows: [], + headRows: pageHead, + footRows: pageFoot, + isFirstPage: true, + isLastPage: true, + }); + } + + return { fragments, columnWidths }; +} + +function shouldShowHead( + headRepeat: "none" | "firstPage" | "everyPage", + isFirstPage: boolean, +): boolean { + if (headRepeat === "everyPage") { + return true; + } + if (headRepeat === "firstPage") { + return isFirstPage; + } + return false; +} + +function shouldShowFoot( + footRepeat: "none" | "lastPage" | "everyPage", + isLastPage: boolean, +): boolean { + if (footRepeat === "everyPage") { + return true; + } + if (footRepeat === "lastPage") { + return isLastPage; + } + return false; +} + +function fillPageRows(remainingRows: MeasuredRow[], availableHeight: number): MeasuredRow[] { + const pageRows: MeasuredRow[] = []; + let remainingHeight = availableHeight; + + while (remainingRows.length > 0) { + const row = remainingRows[0]; + + if (row.rowHeight <= remainingHeight + EPSILON) { + pageRows.push(remainingRows.shift()!); + remainingHeight -= row.rowHeight; + continue; + } + + if (!row.keepTogether) { + const split = splitRow(row, remainingHeight); + if (split) { + pageRows.push(split.first); + remainingRows[0] = split.rest; + } + } + + break; + } + + return pageRows; +} + +function getBodyCapacity( + boundsHeight: number, + pageHead: readonly MeasuredRow[], + pageFoot: readonly MeasuredRow[], +): number { + const capacity = boundsHeight - sumRowHeights(pageHead) - sumRowHeights(pageFoot); + if (capacity < -EPSILON) { + throw new TableLayoutError("Table header/footer exceed available page height"); + } + return Math.max(capacity, 0); +} + +function sumRowHeights(rows: readonly MeasuredRow[]): number { + return rows.reduce((sum, row) => sum + row.rowHeight, 0); +} + +/** + * Split a row at a given height boundary. + * Only works for text-only cells — splits at line boundaries. + */ +function splitRow( + row: MeasuredRow, + availableHeight: number, +): { first: MeasuredRow; rest: MeasuredRow } | null { + if (availableHeight <= 0) { + return null; + } + + const firstCells = []; + const restCells = []; + let maxFirstHeight = 0; + let maxRestHeight = 0; + let didSplit = false; + + for (const cell of row.cells) { + const { padding } = cell.style; + const availForText = availableHeight - padding.top - padding.bottom; + const linesPerPage = Math.floor((availForText + EPSILON) / cell.style.lineHeight); + + if (linesPerPage <= 0) { + return null; + } + + if (linesPerPage >= cell.lines.length) { + // Entire cell fits on first page + firstCells.push({ ...cell }); + restCells.push({ + ...cell, + text: "", + lines: [], + contentHeight: 0, + cellHeight: padding.top + padding.bottom, + }); + maxFirstHeight = Math.max(maxFirstHeight, cell.cellHeight); + maxRestHeight = Math.max(maxRestHeight, padding.top + padding.bottom); + } else { + const firstLines = cell.lines.slice(0, linesPerPage); + const restLines = cell.lines.slice(linesPerPage); + + if (firstLines.length === 0 || restLines.length === 0) { + return null; + } + + didSplit = true; + + const firstTextHeight = firstLines.length * cell.style.lineHeight; + const restTextHeight = restLines.length * cell.style.lineHeight; + + firstCells.push({ + ...cell, + text: firstLines.map(l => l.text).join("\n"), + lines: firstLines, + contentHeight: firstTextHeight, + cellHeight: firstTextHeight + padding.top + padding.bottom, + }); + + restCells.push({ + ...cell, + text: restLines.map(l => l.text).join("\n"), + lines: restLines, + contentHeight: restTextHeight, + cellHeight: restTextHeight + padding.top + padding.bottom, + }); + + maxFirstHeight = Math.max(maxFirstHeight, firstTextHeight + padding.top + padding.bottom); + maxRestHeight = Math.max(maxRestHeight, restTextHeight + padding.top + padding.bottom); + } + } + + if (!didSplit || maxFirstHeight <= 0 || maxFirstHeight > availableHeight + EPSILON) { + return null; + } + + return { + first: { + cells: firstCells, + section: row.section, + keepTogether: false, + bodyIndex: row.bodyIndex, + rowHeight: maxFirstHeight, + }, + rest: { + cells: restCells, + section: row.section, + keepTogether: false, + bodyIndex: row.bodyIndex, + rowHeight: maxRestHeight, + }, + }; +} diff --git a/src/tables/measure.test.ts b/src/tables/measure.test.ts new file mode 100644 index 0000000..4a7b93c --- /dev/null +++ b/src/tables/measure.test.ts @@ -0,0 +1,172 @@ +import { black } from "#src/helpers/colors"; +import { describe, expect, it } from "vitest"; + +import { layoutCellText, resolveColumnWidths, TableLayoutError } from "./measure"; +import { normalizeTable } from "./normalize"; +import type { DrawTableOptions, ResolvedCellStyle, TableDefinition } from "./types"; + +const baseStyle: ResolvedCellStyle = { + font: "Helvetica", + fontSize: 12, + lineHeight: 14, + textColor: black, + fillColor: undefined, + padding: { top: 4, right: 4, bottom: 4, left: 4 }, + align: "left", + valign: "top", + overflow: "wrap", + overflowWrap: "break-word", +}; + +describe("layoutCellText", () => { + it("returns a single line for short text", () => { + const lines = layoutCellText("Hello", baseStyle, 200); + expect(lines).toHaveLength(1); + expect(lines[0].text).toBe("Hello"); + expect(lines[0].width).toBeGreaterThan(0); + }); + + it("wraps text at word boundaries", () => { + const lines = layoutCellText("Hello World Foo Bar", baseStyle, 50); + expect(lines.length).toBeGreaterThan(1); + }); + + it("breaks long words with break-word", () => { + const style = { ...baseStyle, overflowWrap: "break-word" as const }; + const lines = layoutCellText("ABCDEFGHIJKLMNOPQRSTUVWXYZ", style, 30); + expect(lines.length).toBeGreaterThan(1); + // Each line fragment should fit within maxWidth + for (const line of lines) { + expect(line.width).toBeLessThanOrEqual(30 + 1); // small tolerance + } + }); + + it("does not break words with word wrap mode", () => { + const style = { ...baseStyle, overflowWrap: "word" as const }; + const lines = layoutCellText("ABCDEFGHIJKLMNOP", style, 30); + // Long word kept intact on one line + expect(lines).toHaveLength(1); + expect(lines[0].text).toBe("ABCDEFGHIJKLMNOP"); + }); + + it("handles clip overflow", () => { + const style = { ...baseStyle, overflow: "clip" as const }; + const lines = layoutCellText("Hello World This Is Long", style, 40); + expect(lines).toHaveLength(1); + expect(lines[0].width).toBeLessThanOrEqual(40 + 1); + }); + + it("handles ellipsis overflow", () => { + const style = { ...baseStyle, overflow: "ellipsis" as const }; + const lines = layoutCellText("Hello World This Is Very Long Text", style, 60); + expect(lines).toHaveLength(1); + expect(lines[0].text).toContain("..."); + expect(lines[0].width).toBeLessThanOrEqual(60 + 1); + }); + + it("clips the ellipsis token itself when the column is extremely narrow", () => { + const style = { ...baseStyle, overflow: "ellipsis" as const }; + const lines = layoutCellText("Hello", style, 1); + + expect(lines).toHaveLength(1); + expect(lines[0].width).toBeLessThanOrEqual(1 + 0.001); + expect(lines[0].text.length).toBeLessThanOrEqual(3); + }); + + it("returns empty text for empty input", () => { + const lines = layoutCellText("", baseStyle, 200); + expect(lines).toHaveLength(1); + expect(lines[0].text).toBe(""); + }); + + it("handles newlines in text", () => { + const lines = layoutCellText("Line 1\nLine 2\nLine 3", baseStyle, 200); + expect(lines).toHaveLength(3); + expect(lines[0].text).toBe("Line 1"); + expect(lines[1].text).toBe("Line 2"); + expect(lines[2].text).toBe("Line 3"); + }); +}); + +describe("resolveColumnWidths", () => { + const baseBounds = { x: 0, y: 0, width: 500, height: 700 }; + const baseOptions: DrawTableOptions = { bounds: baseBounds }; + + it("resolves fixed-width columns", () => { + const def: TableDefinition = { + columns: [ + { key: "a", width: 100 }, + { key: "b", width: 200 }, + ], + body: [["x", "y"]], + }; + const table = normalizeTable(def, baseOptions); + const widths = resolveColumnWidths(table, 500); + + expect(widths[0]).toBe(100); + expect(widths[1]).toBe(200); + }); + + it("distributes star columns equally", () => { + const def: TableDefinition = { + columns: [ + { key: "a", width: 100 }, + { key: "b", width: "*" }, + { key: "c", width: "*" }, + ], + body: [["x", "y", "z"]], + }; + const table = normalizeTable(def, baseOptions); + const widths = resolveColumnWidths(table, 500); + + expect(widths[0]).toBe(100); + expect(widths[1]).toBe(200); + expect(widths[2]).toBe(200); + }); + + it("distributes weighted star columns", () => { + const def: TableDefinition = { + columns: [ + { key: "a", width: { star: 1 } }, + { key: "b", width: { star: 3 } }, + ], + body: [["x", "y"]], + }; + const table = normalizeTable(def, baseOptions); + const widths = resolveColumnWidths(table, 400); + + expect(widths[0]).toBe(100); + expect(widths[1]).toBe(300); + }); + + it("throws when columns exceed available width", () => { + const def: TableDefinition = { + columns: [ + { key: "a", width: 300 }, + { key: "b", width: 300 }, + ], + body: [["x", "y"]], + }; + const table = normalizeTable(def, baseOptions); + + expect(() => resolveColumnWidths(table, 500)).toThrow(TableLayoutError); + }); + + it("auto columns measure content width", () => { + const def: TableDefinition = { + columns: [ + { key: "a", width: "auto" }, + { key: "b", width: "*" }, + ], + body: [["Short", "Fill"]], + }; + const table = normalizeTable(def, baseOptions); + const widths = resolveColumnWidths(table, 500); + + // Auto column should be roughly the text width + padding + expect(widths[0]).toBeGreaterThan(0); + expect(widths[0]).toBeLessThan(200); + // Star column gets the rest + expect(widths[0] + widths[1]).toBeCloseTo(500, 0); + }); +}); diff --git a/src/tables/measure.ts b/src/tables/measure.ts new file mode 100644 index 0000000..5a301f7 --- /dev/null +++ b/src/tables/measure.ts @@ -0,0 +1,380 @@ +import { measureText } from "#src/drawing/text-layout"; +import type { FontInput } from "#src/drawing/types"; + +import type { + MeasuredCell, + MeasuredRow, + NormalizedRow, + NormalizedTable, + ResolvedCellStyle, + TableColumn, +} from "./types"; + +// ───────────────────────────────────────────────────────────────────────────── +// Text breaking and overflow +// ───────────────────────────────────────────────────────────────────────────── + +interface TextLine { + text: string; + width: number; +} + +export function layoutCellText( + text: string, + style: ResolvedCellStyle, + maxWidth: number, +): TextLine[] { + if (text === "") { + return [{ text: "", width: 0 }]; + } + if (maxWidth <= 0) { + return [{ text: "", width: 0 }]; + } + + const { font, fontSize, overflow, overflowWrap } = style; + + if (overflow === "clip") { + return clipText(text, font, fontSize, maxWidth); + } + + if (overflow === "ellipsis") { + return ellipsisText(text, font, fontSize, maxWidth); + } + + // overflow === "wrap" + const paragraphs = text.split(/\r\n|\r|\n/); + const lines: TextLine[] = []; + + for (const paragraph of paragraphs) { + if (paragraph === "") { + lines.push({ text: "", width: 0 }); + continue; + } + + const words = paragraph.split(/\s+/).filter(w => w.length > 0); + if (words.length === 0) { + lines.push({ text: "", width: 0 }); + continue; + } + + const spaceWidth = measureText(" ", font, fontSize); + let currentLine = ""; + let currentWidth = 0; + + for (const word of words) { + const wordWidth = measureText(word, font, fontSize); + + if (currentLine === "") { + ({ currentLine, currentWidth } = placeWord( + word, + wordWidth, + lines, + font, + fontSize, + maxWidth, + overflowWrap, + )); + } else { + const testWidth = currentWidth + spaceWidth + wordWidth; + if (testWidth <= maxWidth) { + currentLine += ` ${word}`; + currentWidth = testWidth; + } else { + lines.push({ text: currentLine, width: currentWidth }); + ({ currentLine, currentWidth } = placeWord( + word, + wordWidth, + lines, + font, + fontSize, + maxWidth, + overflowWrap, + )); + } + } + } + + if (currentLine !== "") { + lines.push({ text: currentLine, width: currentWidth }); + } + } + + return lines.length > 0 ? lines : [{ text: "", width: 0 }]; +} + +function placeWord( + word: string, + wordWidth: number, + lines: TextLine[], + font: FontInput, + fontSize: number, + maxWidth: number, + overflowWrap: string, +): { currentLine: string; currentWidth: number } { + if (wordWidth > maxWidth && overflowWrap === "break-word") { + const broken = breakWord(word, font, fontSize, maxWidth); + for (let i = 0; i < broken.length - 1; i++) { + lines.push(broken[i]); + } + const last = broken[broken.length - 1]; + return { currentLine: last.text, currentWidth: last.width }; + } + return { currentLine: word, currentWidth: wordWidth }; +} + +function breakWord(word: string, font: FontInput, fontSize: number, maxWidth: number): TextLine[] { + const lines: TextLine[] = []; + let current = ""; + let currentWidth = 0; + + for (const char of word) { + const charWidth = measureText(char, font, fontSize); + if (currentWidth + charWidth > maxWidth && current !== "") { + lines.push({ text: current, width: currentWidth }); + current = char; + currentWidth = charWidth; + } else { + current += char; + currentWidth += charWidth; + } + } + + if (current !== "") { + lines.push({ text: current, width: currentWidth }); + } + + return lines; +} + +function clipText(text: string, font: FontInput, fontSize: number, maxWidth: number): TextLine[] { + const firstLine = text.split(/\r\n|\r|\n/)[0]; + let clipped = ""; + let width = 0; + + for (const char of firstLine) { + const charWidth = measureText(char, font, fontSize); + if (width + charWidth > maxWidth) { + break; + } + clipped += char; + width += charWidth; + } + + return [{ text: clipped, width }]; +} + +function ellipsisText( + text: string, + font: FontInput, + fontSize: number, + maxWidth: number, +): TextLine[] { + if (maxWidth <= 0) { + return [{ text: "", width: 0 }]; + } + + const firstLine = text.split(/\r\n|\r|\n/)[0]; + const fullWidth = measureText(firstLine, font, fontSize); + + if (fullWidth <= maxWidth) { + return [{ text: firstLine, width: fullWidth }]; + } + + const ellipsis = "..."; + const ellipsisWidth = measureText(ellipsis, font, fontSize); + + if (ellipsisWidth > maxWidth) { + return clipText(ellipsis, font, fontSize, maxWidth); + } + + const availWidth = maxWidth - ellipsisWidth; + + let clipped = ""; + let width = 0; + + for (const char of firstLine) { + const charWidth = measureText(char, font, fontSize); + if (width + charWidth > availWidth) { + break; + } + clipped += char; + width += charWidth; + } + + return [{ text: clipped + ellipsis, width: width + ellipsisWidth }]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cell and row measurement +// ───────────────────────────────────────────────────────────────────────────── + +function measureCell( + cell: { text: string; style: ResolvedCellStyle }, + columnWidth: number, +): MeasuredCell { + const { padding } = cell.style; + const contentWidth = columnWidth - padding.left - padding.right; + const lines = layoutCellText(cell.text, cell.style, contentWidth); + + const textHeight = lines.length * cell.style.lineHeight; + const cellHeight = textHeight + padding.top + padding.bottom; + + return { + text: cell.text, + style: cell.style, + lines, + contentWidth, + contentHeight: textHeight, + cellHeight, + }; +} + +export function measureRow(row: NormalizedRow, columnWidths: number[]): MeasuredRow { + const cells = row.cells.map((cell, i) => measureCell(cell, columnWidths[i])); + const rowHeight = Math.max(...cells.map(c => c.cellHeight), 0); + + return { + cells, + section: row.section, + keepTogether: row.keepTogether, + bodyIndex: row.bodyIndex, + rowHeight, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Column width resolution +// ───────────────────────────────────────────────────────────────────────────── + +export function resolveColumnWidths(table: NormalizedTable, availableWidth: number): number[] { + const { columns } = table; + const widths = new Array(columns.length).fill(0); + const allRows = [...table.head, ...table.body, ...table.foot]; + + // Pass 1: lock fixed-width columns + let fixedTotal = 0; + const autoIndices: number[] = []; + const starIndices: number[] = []; + const starWeights: number[] = []; + + for (let i = 0; i < columns.length; i++) { + const col = columns[i]; + const w = col.width ?? "*"; + + if (typeof w === "number") { + widths[i] = w; + fixedTotal += w; + } else if (w === "auto") { + autoIndices.push(i); + } else { + // "*" or { star: n } + starIndices.push(i); + starWeights.push(typeof w === "object" ? w.star : 1); + } + } + + // Pass 2: auto columns + let autoTotal = 0; + for (const i of autoIndices) { + const col = columns[i]; + + if (col.minWidth !== undefined && col.maxWidth !== undefined) { + widths[i] = col.maxWidth; + autoTotal += col.maxWidth; + continue; + } + + let preferred = 0; + for (const row of allRows) { + const cell = row.cells[i]; + const textWidth = measureText(cell.text, cell.style.font, cell.style.fontSize); + const cellPreferred = textWidth + cell.style.padding.left + cell.style.padding.right; + preferred = Math.max(preferred, cellPreferred); + } + + if (col.minWidth !== undefined) { + preferred = Math.max(preferred, col.minWidth); + } + if (col.maxWidth !== undefined) { + preferred = Math.min(preferred, col.maxWidth); + } + + widths[i] = preferred; + autoTotal += preferred; + } + + // Pass 3: star columns get remaining width + let remaining = availableWidth - fixedTotal - autoTotal; + if (remaining < 0) { + remaining = 0; + } + + if (starIndices.length > 0) { + const totalWeight = starWeights.reduce((a, b) => a + b, 0); + for (let j = 0; j < starIndices.length; j++) { + const i = starIndices[j]; + const col = columns[i]; + let w = (starWeights[j] / totalWeight) * remaining; + + if (col.minWidth !== undefined) { + w = Math.max(w, col.minWidth); + } + if (col.maxWidth !== undefined) { + w = Math.min(w, col.maxWidth); + } + + widths[i] = w; + } + } + + const total = widths.reduce((a, b) => a + b, 0); + if (total > availableWidth + 0.01) { + const shrinkableIndices = [...autoIndices, ...starIndices]; + let excess = total - availableWidth; + + for (const i of shrinkableIndices) { + if (excess <= 0) { + break; + } + const col = columns[i]; + const min = col.minWidth ?? 0; + const shrinkable = widths[i] - min; + if (shrinkable > 0) { + const shrink = Math.min(shrinkable, excess); + widths[i] -= shrink; + excess -= shrink; + } + } + + if (excess > 0.01) { + throw new TableLayoutError( + `Table width ${total.toFixed(1)}pt exceeds available ${availableWidth.toFixed(1)}pt`, + ); + } + } + + return widths; +} + +export class TableLayoutError extends Error { + constructor(message: string) { + super(message); + this.name = "TableLayoutError"; + } +} + +export class TableRowOverflowError extends TableLayoutError { + rowIndex: number; + measuredHeight: number; + availableHeight: number; + + constructor(rowIndex: number, measuredHeight: number, availableHeight: number) { + super( + `Row ${rowIndex} height ${measuredHeight.toFixed(1)}pt exceeds available page height ${availableHeight.toFixed(1)}pt`, + ); + this.name = "TableRowOverflowError"; + this.rowIndex = rowIndex; + this.measuredHeight = measuredHeight; + this.availableHeight = availableHeight; + } +} diff --git a/src/tables/normalize.test.ts b/src/tables/normalize.test.ts new file mode 100644 index 0000000..b69e6bc --- /dev/null +++ b/src/tables/normalize.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; + +import { normalizeTable } from "./normalize"; +import type { DrawTableOptions, TableDefinition } from "./types"; + +const baseBounds = { x: 0, y: 0, width: 500, height: 700 }; +const baseOptions: DrawTableOptions = { bounds: baseBounds }; + +describe("normalizeTable", () => { + it("normalizes a simple array-based table", () => { + const def: TableDefinition = { + columns: [{ key: "a" }, { key: "b" }], + body: [["hello", "world"]], + }; + + const result = normalizeTable(def, baseOptions); + + expect(result.columns).toHaveLength(2); + expect(result.body).toHaveLength(1); + expect(result.body[0].cells).toHaveLength(2); + expect(result.body[0].cells[0].text).toBe("hello"); + expect(result.body[0].cells[1].text).toBe("world"); + expect(result.body[0].section).toBe("body"); + }); + + it("normalizes sparse rows using column keys", () => { + const def: TableDefinition = { + columns: [{ key: "a" }, { key: "b" }, { key: "c" }], + body: [{ cells: { b: "middle", c: "end" } }], + }; + + const result = normalizeTable(def, baseOptions); + + expect(result.body[0].cells[0].text).toBe(""); + expect(result.body[0].cells[1].text).toBe("middle"); + expect(result.body[0].cells[2].text).toBe("end"); + }); + + it("pads short rows with empty cells and warns", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const def: TableDefinition = { + columns: [{ key: "a" }, { key: "b" }, { key: "c" }], + body: [["only-one"]], + }; + + const result = normalizeTable(def, baseOptions); + + expect(result.body[0].cells).toHaveLength(3); + expect(result.body[0].cells[0].text).toBe("only-one"); + expect(result.body[0].cells[1].text).toBe(""); + expect(result.body[0].cells[2].text).toBe(""); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("truncates extra cells and warns", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const def: TableDefinition = { + columns: [{ key: "a" }], + body: [["one", "two", "three"]], + }; + + const result = normalizeTable(def, baseOptions); + + expect(result.body[0].cells).toHaveLength(1); + expect(result.body[0].cells[0].text).toBe("one"); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("normalizes head, body, and foot sections", () => { + const def: TableDefinition = { + columns: [{ key: "a" }], + head: [["Header"]], + body: [["Row 1"], ["Row 2"]], + foot: [["Footer"]], + }; + + const result = normalizeTable(def, baseOptions); + + expect(result.head).toHaveLength(1); + expect(result.head[0].section).toBe("head"); + expect(result.body).toHaveLength(2); + expect(result.body[0].section).toBe("body"); + expect(result.foot).toHaveLength(1); + expect(result.foot[0].section).toBe("foot"); + }); + + it("preserves keepTogether from full row definitions", () => { + const def: TableDefinition = { + columns: [{ key: "a" }], + body: [{ cells: ["data"], keepTogether: true }], + }; + + const result = normalizeTable(def, baseOptions); + expect(result.body[0].keepTogether).toBe(true); + }); + + it("throws on empty columns", () => { + const def: TableDefinition = { columns: [], body: [] }; + expect(() => normalizeTable(def, baseOptions)).toThrow("at least one column"); + }); + + it("handles TableCell objects in rows", () => { + const def: TableDefinition = { + columns: [{ key: "a" }], + body: [[{ text: "styled", align: "right" }]], + }; + + const result = normalizeTable(def, baseOptions); + expect(result.body[0].cells[0].text).toBe("styled"); + expect(result.body[0].cells[0].style.align).toBe("right"); + }); +}); diff --git a/src/tables/normalize.ts b/src/tables/normalize.ts new file mode 100644 index 0000000..2c7bc25 --- /dev/null +++ b/src/tables/normalize.ts @@ -0,0 +1,160 @@ +import { mergeStyle, resolveStyleCascade } from "./style"; +import type { + DrawTableOptions, + NormalizedRow, + NormalizedTable, + ResolvedCellStyle, + Section, + TableCell, + TableCellStyle, + TableColumn, + TableDefinition, + TableFullRow, + TableRow, + TableSparseRow, +} from "./types"; + +function isSparseRow(row: TableRow): row is TableSparseRow { + return ( + typeof row === "object" && !Array.isArray(row) && "cells" in row && !Array.isArray(row.cells) + ); +} + +function isFullRow(row: TableRow): row is TableFullRow { + return ( + typeof row === "object" && !Array.isArray(row) && "cells" in row && Array.isArray(row.cells) + ); +} + +function resolveSparseRow( + row: TableSparseRow, + columns: readonly TableColumn[], +): (string | TableCell)[] { + const cells: (string | TableCell)[] = new Array(columns.length).fill(""); + for (const [key, value] of Object.entries(row.cells)) { + const colIndex = columns.findIndex(c => c.key === key); + if (colIndex !== -1) { + cells[colIndex] = value; + } + } + return cells; +} + +function extractCells(row: TableRow, columns: readonly TableColumn[]): (string | TableCell)[] { + if (Array.isArray(row)) { + return row as (string | TableCell)[]; + } + if (isSparseRow(row)) { + return resolveSparseRow(row, columns); + } + if (isFullRow(row)) { + return row.cells as (string | TableCell)[]; + } + return row as (string | TableCell)[]; +} + +function extractRowMeta(row: TableRow): { + keepTogether: boolean; + style?: Partial; +} { + if (Array.isArray(row)) { + return { keepTogether: false }; + } + if (typeof row === "object" && "cells" in row) { + return { + keepTogether: row.keepTogether ?? false, + style: row.style, + }; + } + return { keepTogether: false }; +} + +function normalizeRow( + row: TableRow, + columns: readonly TableColumn[], + section: Section, + bodyIndex: number, + styles: Map, +): NormalizedRow { + const rawCells = extractCells(row, columns); + const meta = extractRowMeta(row); + + let padded = [...rawCells]; + if (padded.length < columns.length) { + if (padded.length > 0) { + console.warn( + `Table row has ${padded.length} cells but ${columns.length} columns — padding with empty cells`, + ); + } + while (padded.length < columns.length) { + padded.push(""); + } + } else if (padded.length > columns.length) { + console.warn( + `Table row has ${padded.length} cells but ${columns.length} columns — extra cells ignored`, + ); + padded = padded.slice(0, columns.length); + } + + const cells = padded.map((cell, colIndex) => { + const text = typeof cell === "string" ? cell : cell.text; + const cellOverrides = typeof cell === "string" ? undefined : cell.style; + const cellAlign = typeof cell === "string" ? undefined : cell.align; + const cellValign = typeof cell === "string" ? undefined : cell.valign; + const cellOverflow = typeof cell === "string" ? undefined : cell.overflow; + const cellOverflowWrap = typeof cell === "string" ? undefined : cell.overflowWrap; + + const key = columns[colIndex].key; + const baseStyle = styles.get(`${section}:${bodyIndex}:${key}`)!; + + let style = baseStyle; + + if (meta.style) { + style = mergeStyle(style, meta.style); + } + + if (cellOverrides || cellAlign || cellValign || cellOverflow || cellOverflowWrap) { + style = mergeStyle(style, cellOverrides); + if (cellAlign) { + style = { ...style, align: cellAlign }; + } + if (cellValign) { + style = { ...style, valign: cellValign }; + } + if (cellOverflow) { + style = { ...style, overflow: cellOverflow }; + } + if (cellOverflowWrap) { + style = { ...style, overflowWrap: cellOverflowWrap }; + } + } + + return { text, style }; + }); + + return { cells, section, keepTogether: meta.keepTogether, bodyIndex }; +} + +export function normalizeTable( + definition: TableDefinition, + options: DrawTableOptions, +): NormalizedTable { + const columns = [...definition.columns]; + if (columns.length === 0) { + throw new Error("Table must have at least one column"); + } + + const styles = resolveStyleCascade(definition, options, columns); + + const head = (definition.head ?? []).map((row, i) => + normalizeRow(row, columns, "head", i, styles), + ); + + const body = definition.body.map((row, i) => normalizeRow(row, columns, "body", i, styles)); + + const foot = (definition.foot ?? []).map((row, i) => + normalizeRow(row, columns, "foot", i, styles), + ); + + return { columns, head, body, foot }; +} diff --git a/src/tables/render.ts b/src/tables/render.ts new file mode 100644 index 0000000..600980a --- /dev/null +++ b/src/tables/render.ts @@ -0,0 +1,220 @@ +import type { PDFPage } from "#src/api/pdf-page"; + +import type { TableLayoutResult } from "./layout"; +import type { + DrawTableOptions, + DrawTableResult, + MeasuredCell, + MeasuredRow, + PageFragment, +} from "./types"; + +interface RenderContext { + fragments: PageFragment[]; + columnWidths: number[]; + options: DrawTableOptions; + startPage: PDFPage; + addPage: () => PDFPage; +} + +export function renderTable( + layout: TableLayoutResult, + options: DrawTableOptions, + startPage: PDFPage, + addPage: () => PDFPage, +): DrawTableResult { + const ctx: RenderContext = { + fragments: layout.fragments, + columnWidths: layout.columnWidths, + options, + startPage, + addPage, + }; + + const usedPages: PDFPage[] = []; + let currentPage = startPage; + let cursorY = 0; + const seenBodyIndices = new Set(); + + for (let fi = 0; fi < ctx.fragments.length; fi++) { + const fragment = ctx.fragments[fi]; + + if (fi > 0) { + currentPage = ctx.addPage(); + } + usedPages.push(currentPage); + + cursorY = renderFragment(currentPage, fragment, ctx); + for (const row of fragment.rows) { + seenBodyIndices.add(row.bodyIndex); + } + } + + return { + lastPage: currentPage, + usedPages, + cursorY, + rowCountDrawn: seenBodyIndices.size, + }; +} + +function renderFragment(page: PDFPage, fragment: PageFragment, ctx: RenderContext): number { + const { bounds } = ctx.options; + // PDF y-up: top of bounds is y + height + const topY = bounds.y + bounds.height; + let cursor = topY; + + for (const row of fragment.headRows) { + drawRow(page, row, cursor, ctx); + cursor -= row.rowHeight; + } + + for (const row of fragment.rows) { + drawRow(page, row, cursor, ctx); + cursor -= row.rowHeight; + } + + for (const row of fragment.footRows) { + drawRow(page, row, cursor, ctx); + cursor -= row.rowHeight; + } + + const allRows = [...fragment.headRows, ...fragment.rows, ...fragment.footRows]; + drawGrid(page, allRows, topY, ctx); + + return cursor; +} + +function drawRow(page: PDFPage, row: MeasuredRow, topY: number, ctx: RenderContext): void { + const { bounds } = ctx.options; + let x = bounds.x; + + for (let i = 0; i < row.cells.length; i++) { + const cell = row.cells[i]; + const colWidth = ctx.columnWidths[i]; + + const cellBottomY = topY - row.rowHeight; + + if (cell.style.fillColor) { + page.drawRectangle({ + x, + y: cellBottomY, + width: colWidth, + height: row.rowHeight, + color: cell.style.fillColor, + }); + } + + drawCellText(page, cell, x, topY, cellBottomY, colWidth, row.rowHeight); + + x += colWidth; + } +} + +function drawCellText( + page: PDFPage, + cell: MeasuredCell, + cellX: number, + cellTopY: number, + cellBottomY: number, + colWidth: number, + rowHeight: number, +): void { + const { padding, align, valign, font, fontSize, lineHeight, textColor } = cell.style; + + if (cell.lines.length === 0 || (cell.lines.length === 1 && cell.lines[0].text === "")) { + return; + } + + const contentAreaWidth = colWidth - padding.left - padding.right; + const textBlockHeight = cell.lines.length * lineHeight; + const innerHeight = rowHeight - padding.top - padding.bottom; + + let textTopY: number; + if (valign === "middle") { + textTopY = cellTopY - padding.top - (innerHeight - textBlockHeight) / 2; + } else if (valign === "bottom") { + textTopY = cellBottomY + padding.bottom + textBlockHeight; + } else { + textTopY = cellTopY - padding.top; + } + + for (let li = 0; li < cell.lines.length; li++) { + const line = cell.lines[li]; + if (line.text === "") { + continue; + } + + let lineX = cellX + padding.left; + if (align === "center") { + lineX += (contentAreaWidth - line.width) / 2; + } else if (align === "right") { + lineX += contentAreaWidth - line.width; + } + + const baselineY = textTopY - (li + 1) * lineHeight + (lineHeight - fontSize) / 2; + + page.drawText(line.text, { + x: lineX, + y: baselineY, + font, + size: fontSize, + color: textColor, + }); + } +} + +function drawGrid(page: PDFPage, rows: MeasuredRow[], topY: number, ctx: RenderContext): void { + const { bounds } = ctx.options; + const outerWidth = ctx.options.outerBorderWidth ?? 0.5; + const innerWidth = ctx.options.innerBorderWidth ?? 0.5; + const outerColor = ctx.options.outerBorderColor; + const innerColor = ctx.options.innerBorderColor; + + if (rows.length === 0) { + return; + } + + const totalHeight = rows.reduce((s, r) => s + r.rowHeight, 0); + const tableWidth = ctx.columnWidths.reduce((s, w) => s + w, 0); + const bottomY = topY - totalHeight; + + if (outerWidth > 0) { + page.drawRectangle({ + x: bounds.x, + y: bottomY, + width: tableWidth, + height: totalHeight, + borderWidth: outerWidth, + borderColor: outerColor, + }); + } + + // Inner horizontal lines (between rows) + if (innerWidth > 0 && rows.length > 1) { + let y = topY; + for (let i = 0; i < rows.length - 1; i++) { + y -= rows[i].rowHeight; + page.drawLine({ + start: { x: bounds.x, y }, + end: { x: bounds.x + tableWidth, y }, + color: innerColor, + thickness: innerWidth, + }); + } + } + + // Inner vertical lines (between columns) + if (innerWidth > 0 && ctx.columnWidths.length > 1) { + let x = bounds.x; + for (let i = 0; i < ctx.columnWidths.length - 1; i++) { + x += ctx.columnWidths[i]; + page.drawLine({ + start: { x, y: topY }, + end: { x, y: bottomY }, + color: innerColor, + thickness: innerWidth, + }); + } + } +} diff --git a/src/tables/style.ts b/src/tables/style.ts new file mode 100644 index 0000000..476d2f9 --- /dev/null +++ b/src/tables/style.ts @@ -0,0 +1,139 @@ +import { black } from "#src/helpers/colors"; + +import type { + DrawTableOptions, + ResolvedCellStyle, + ResolvedPadding, + Section, + TableCellStyle, + TableColumn, + TableDefinition, +} from "./types"; + +const DEFAULT_STYLE: ResolvedCellStyle = { + font: "Helvetica", + fontSize: 12, + lineHeight: 14.4, + textColor: black, + fillColor: undefined, + padding: { top: 4, right: 4, bottom: 4, left: 4 }, + align: "left", + valign: "top", + overflow: "wrap", + overflowWrap: "break-word", +}; + +export function resolvePadding( + padding: number | { top?: number; right?: number; bottom?: number; left?: number } | undefined, + fallback: ResolvedPadding, +): ResolvedPadding { + if (padding === undefined) { + return fallback; + } + if (typeof padding === "number") { + return { top: padding, right: padding, bottom: padding, left: padding }; + } + return { + top: padding.top ?? fallback.top, + right: padding.right ?? fallback.right, + bottom: padding.bottom ?? fallback.bottom, + left: padding.left ?? fallback.left, + }; +} + +export function mergeStyle( + base: ResolvedCellStyle, + partial: Partial | undefined, +): ResolvedCellStyle { + if (!partial) { + return base; + } + return { + font: partial.font ?? base.font, + fontSize: partial.fontSize ?? base.fontSize, + lineHeight: partial.lineHeight ?? base.lineHeight, + textColor: partial.textColor ?? base.textColor, + fillColor: partial.fillColor !== undefined ? partial.fillColor : base.fillColor, + padding: resolvePadding(partial.padding, base.padding), + align: partial.align ?? base.align, + valign: partial.valign ?? base.valign, + overflow: partial.overflow ?? base.overflow, + overflowWrap: partial.overflowWrap ?? base.overflowWrap, + }; +} + +/** + * Pre-compute the resolved style for every cell position in the table. + * + * Cascade order (later wins): + * 1. library defaults + * 2. table-wide `style` + * 3. section style (`headStyle`, `bodyStyle`, `footStyle`) + * 4. `alternateRowStyle` (body only, continuous counter) + * 5. column style (`columnStyles[key]`) + * 6. row style (handled in normalize.ts at extraction time) + * 7. cell style (handled in normalize.ts at extraction time) + * + * Returns a map keyed by `"section:rowIndex:columnKey"`. + * Levels 6 and 7 are applied in normalize.ts since they depend on per-row/cell data. + */ +export function resolveStyleCascade( + definition: TableDefinition, + options: DrawTableOptions, + columns: TableColumn[], +): Map { + const styles = new Map(); + + // Level 1: defaults + let base = { ...DEFAULT_STYLE }; + + // Level 2: table-wide style + base = mergeStyle(base, options.style); + + const sectionStyleMap: Record | undefined> = { + head: options.headStyle, + body: options.bodyStyle, + foot: options.footStyle, + }; + + const sections: { name: Section; rows: readonly import("./types").TableRow[] }[] = [ + { name: "head", rows: definition.head ?? [] }, + { name: "body", rows: definition.body }, + { name: "foot", rows: definition.foot ?? [] }, + ]; + + for (const { name: section, rows } of sections) { + // Level 3: section style + const sectionBase = mergeStyle(base, sectionStyleMap[section]); + + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { + // Level 4: alternate row style (body only) + let rowBase = sectionBase; + if (section === "body" && options.alternateRowStyle && rowIdx % 2 === 1) { + rowBase = mergeStyle(sectionBase, options.alternateRowStyle); + } + + for (const col of columns) { + // Level 5: column style + let cellBase = rowBase; + if (options.columnStyles?.[col.key]) { + cellBase = mergeStyle(rowBase, options.columnStyles[col.key]); + } + + // Level 5.5: column-level align from TableColumn + if (col.align) { + cellBase = { ...cellBase, align: col.align }; + } + + // Column-level style from TableColumn.style + if (col.style) { + cellBase = mergeStyle(cellBase, col.style); + } + + styles.set(`${section}:${rowIdx}:${col.key}`, cellBase); + } + } + } + + return styles; +} diff --git a/src/tables/table.integration.test.ts b/src/tables/table.integration.test.ts new file mode 100644 index 0000000..642735e --- /dev/null +++ b/src/tables/table.integration.test.ts @@ -0,0 +1,448 @@ +import { PDF } from "#src/api/pdf"; +import type { PDFPage } from "#src/api/pdf-page"; +import { ContentStreamParser } from "#src/content/parsing/content-stream-parser"; +import { grayscale } from "#src/helpers/colors"; +import { saveTestOutput } from "#src/test-utils"; +import { describe, expect, it } from "vitest"; + +async function saveTablePdf(name: string, bytes: Uint8Array) { + await saveTestOutput(`tables/${name}.pdf`, bytes); +} + +function getPageOperators(page: PDFPage): string[] { + const contentBytes = (page as unknown as { getContentBytes(): Uint8Array }).getContentBytes(); + return new ContentStreamParser(contentBytes) + .parse() + .operations.map(operation => operation.operator); +} + +function expectSingleMatch(page: PDFPage, text: string) { + const matches = page.findText(text); + expect(matches).toHaveLength(1); + return matches[0]; +} + +function rightEdge(page: PDFPage, text: string) { + const match = expectSingleMatch(page, text); + return match.bbox.x + match.bbox.width; +} + +function currency(value: number) { + return `$${value.toFixed(2)}`; +} + +describe("drawTable integration", () => { + it("renders a single-page table and preserves text after save/reload", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + const result = pdf.drawTable( + page, + { + columns: [ + { key: "name", width: 200 }, + { key: "value", width: "*" }, + ], + head: [["Name", "Value"]], + body: [ + ["Alpha", "100"], + ["Beta", "200"], + ["Gamma", "300"], + ], + }, + { + bounds: { x: 48, y: 72, width: 516, height: 640 }, + style: { fontSize: 11, lineHeight: 14, padding: 6 }, + headStyle: { font: "Helvetica-Bold", fillColor: grayscale(0.85) }, + }, + ); + + expect(result.usedPages).toHaveLength(1); + expect(result.rowCountDrawn).toBe(3); + expect(result.cursorY).toBeGreaterThanOrEqual(72); + expect(result.cursorY).toBeLessThan(72 + 640); + + const bytes = await pdf.save(); + await saveTablePdf("simple-table", bytes); + + const reloaded = await PDF.load(bytes); + expect(reloaded.getPageCount()).toBe(1); + + const text = reloaded.getPage(0)!.extractText().text; + expect(text).toContain("Name"); + expect(text).toContain("Alpha"); + expect(text).toContain("Gamma"); + expect(text).toContain("300"); + }); + + it("paginates an invoice table and draws the totals footer only on the last page", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + const items = Array.from({ length: 50 }, (_, i) => { + const quantity = (i % 5) + 1; + const price = 10 + i * 1.35; + const total = quantity * price; + return { + sku: `SKU-${String(i + 1).padStart(4, "0")}`, + description: `Product description for item ${i + 1} with a deterministic wrapping payload ${i + 11}`, + quantity, + price, + total, + }; + }); + + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const tax = subtotal * 0.08; + const grandTotal = subtotal + tax; + + const result = pdf.drawTable( + page, + { + columns: [ + { key: "sku", width: 72 }, + { key: "desc", width: "*" }, + { key: "qty", width: 40, align: "right" }, + { key: "price", width: 72, align: "right" }, + { key: "total", width: 72, align: "right" }, + ], + head: [["SKU", "Description", "Qty", "Price", "Total"]], + body: items.map(item => [ + item.sku, + item.description, + String(item.quantity), + currency(item.price), + currency(item.total), + ]), + foot: [ + { cells: { price: "Subtotal", total: currency(subtotal) } }, + { cells: { price: "Tax", total: currency(tax) } }, + { cells: { price: "Total", total: currency(grandTotal) } }, + ], + }, + { + bounds: { x: 48, y: 72, width: 516, height: 640 }, + headRepeat: "everyPage", + footRepeat: "lastPage", + style: { fontSize: 10, lineHeight: 13, padding: 5 }, + headStyle: { + font: "Helvetica-Bold", + fillColor: grayscale(0.9), + }, + alternateRowStyle: { fillColor: grayscale(0.97) }, + outerBorderWidth: 1, + innerBorderWidth: 0.5, + }, + ); + + expect(result.usedPages.length).toBeGreaterThan(1); + expect(result.rowCountDrawn).toBe(items.length); + + const bytes = await pdf.save(); + await saveTablePdf("invoice-multi-page", bytes); + + const reloaded = await PDF.load(bytes); + expect(reloaded.getPageCount()).toBe(result.usedPages.length); + + const pageTexts = Array.from( + { length: reloaded.getPageCount() }, + (_, index) => reloaded.getPage(index)!.extractText().text, + ); + + for (const text of pageTexts) { + expect(text).toContain("SKU Description Qty Price Total"); + } + + expect(pageTexts[0]).toContain("SKU-0001"); + expect(pageTexts.at(-1)!).toContain("SKU-0050"); + expect(pageTexts.at(-1)!).toContain("Subtotal"); + expect(pageTexts.at(-1)!).toContain(currency(grandTotal)); + + for (const text of pageTexts.slice(0, -1)) { + expect(text).not.toContain("Subtotal"); + expect(text).not.toContain(currency(grandTotal)); + } + }); + + it("omits border strokes when borders are disabled and still fills alternating rows", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + pdf.drawTable( + page, + { + columns: [ + { key: "metric", width: "*" }, + { key: "value", width: 100, align: "right" }, + ], + body: [ + ["Revenue", "$1,250,000"], + ["Expenses", "$890,000"], + ["Net Income", "$360,000"], + ["Margin", "28.8%"], + ], + }, + { + bounds: { x: 48, y: 500, width: 516, height: 200 }, + style: { fontSize: 11, lineHeight: 14, padding: 8 }, + alternateRowStyle: { fillColor: grayscale(0.95) }, + outerBorderWidth: 0, + innerBorderWidth: 0, + }, + ); + + const bytes = await pdf.save(); + await saveTablePdf("borderless-alternating", bytes); + + const reloaded = await PDF.load(bytes); + const operators = getPageOperators(reloaded.getPage(0)!); + const strokeOperators = new Set(["S", "s", "B", "B*", "b", "b*"]); + + expect(operators.filter(operator => operator === "f")).toHaveLength(4); + expect(operators.some(operator => strokeOperators.has(operator))).toBe(false); + }); + + it("keeps right-aligned numeric columns aligned by their right edge", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + pdf.drawTable( + page, + { + columns: [ + { key: "country", width: "*" }, + { key: "pop", width: 100, align: "right" }, + { key: "gdp", width: 100, align: "right" }, + ], + head: [["Country", "Population", "GDP (B)"]], + body: [ + ["United States", "331,449,281", "$25,462"], + ["China", "1,425,671,352", "$17,963"], + ["Germany", "83,294,633", "$4,072"], + ], + }, + { + bounds: { x: 48, y: 500, width: 400, height: 200 }, + style: { fontSize: 10, lineHeight: 13, padding: 5 }, + headStyle: { font: "Helvetica-Bold" }, + outerBorderWidth: 0.5, + innerBorderWidth: 0.25, + }, + ); + + const bytes = await pdf.save(); + await saveTablePdf("right-aligned-numeric", bytes); + + const reloaded = await PDF.load(bytes); + const reloadedPage = reloaded.getPage(0)!; + + const pop1 = expectSingleMatch(reloadedPage, "331,449,281"); + const pop2 = expectSingleMatch(reloadedPage, "1,425,671,352"); + const gdp1 = expectSingleMatch(reloadedPage, "$25,462"); + const gdp2 = expectSingleMatch(reloadedPage, "$4,072"); + + expect(pop1.bbox.x).not.toBeCloseTo(pop2.bbox.x, 1); + expect(gdp1.bbox.x).not.toBeCloseTo(gdp2.bbox.x, 1); + expect(rightEdge(reloadedPage, "331,449,281")).toBeCloseTo( + rightEdge(reloadedPage, "1,425,671,352"), + 1, + ); + expect(rightEdge(reloadedPage, "$25,462")).toBeCloseTo(rightEdge(reloadedPage, "$4,072"), 1); + }); + + it("persists break-word wrapping for long tokens after save/reload", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + pdf.drawTable( + page, + { + columns: [ + { key: "id", width: 80 }, + { key: "url", width: "*" }, + ], + head: [["ID", "URL"]], + body: [ + ["SKU-0001", "https://example.com/products/very-long-product-name-that-needs-wrapping"], + ["ABCDEFGHIJKLMNOP", "https://a.co/d/verylongidentifierwithnospaces1234567890abcdef"], + ["SHORT", "https://example.com"], + ], + }, + { + bounds: { x: 48, y: 500, width: 400, height: 300 }, + style: { fontSize: 10, lineHeight: 13, padding: 5 }, + headStyle: { font: "Helvetica-Bold" }, + }, + ); + + const bytes = await pdf.save(); + await saveTablePdf("break-word", bytes); + + const reloaded = await PDF.load(bytes); + const lines = reloaded + .getPage(0)! + .extractText() + .lines.map(line => line.text); + + expect(lines.length).toBeGreaterThan(4); + expect(lines.some(line => line.includes("ABCDEFGHIJ"))).toBe(true); + expect(lines).toContain("KLMNOP"); + expect(lines).toContain("pping"); + }); + + it("renders sparse footer rows with only the addressed columns populated", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + pdf.drawTable( + page, + { + columns: [ + { key: "item", width: 200 }, + { key: "qty", width: 60, align: "right" }, + { key: "price", width: 80, align: "right" }, + { key: "total", width: 80, align: "right" }, + ], + head: [["Item", "Qty", "Price", "Total"]], + body: [ + ["Widget A", "5", "$10.00", "$50.00"], + ["Widget B", "3", "$25.00", "$75.00"], + ], + foot: [ + { cells: { price: "Subtotal:", total: "$125.00" } }, + { cells: { price: "Tax:", total: "$10.00" } }, + { cells: { price: "Total:", total: "$135.00" }, style: { font: "Helvetica-Bold" } }, + ], + }, + { + bounds: { x: 48, y: 400, width: 420, height: 300 }, + style: { fontSize: 10, lineHeight: 13, padding: 5 }, + headStyle: { font: "Helvetica-Bold", fillColor: grayscale(0.9) }, + footRepeat: "lastPage", + }, + ); + + const bytes = await pdf.save(); + await saveTablePdf("sparse-footer", bytes); + + const reloaded = await PDF.load(bytes); + const text = reloaded.getPage(0)!.extractText().text; + + expect(text).toContain("Subtotal:"); + expect(text).toContain("$125.00"); + expect(text).toContain("Tax:"); + expect(text).toContain("$10.00"); + expect(text).toContain("Total:"); + expect(text).toContain("$135.00"); + }); + + it("returns a cursorY that can be used to draw content after the table", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + const result = pdf.drawTable( + page, + { + columns: [ + { key: "a", width: "*" }, + { key: "b", width: "*" }, + ], + body: [ + ["Cell 1", "Cell 2"], + ["Cell 3", "Cell 4"], + ], + }, + { + bounds: { x: 48, y: 72, width: 516, height: 640 }, + style: { fontSize: 11, lineHeight: 14, padding: 6 }, + }, + ); + + result.lastPage.drawText("Content after table", { + x: 48, + y: result.cursorY - 20, + size: 12, + }); + + const bytes = await pdf.save(); + await saveTablePdf("content-after-table", bytes); + + const reloaded = await PDF.load(bytes); + const reloadedPage = reloaded.getPage(0)!; + const afterTable = expectSingleMatch(reloadedPage, "Content after table"); + const bodyCell = expectSingleMatch(reloadedPage, "Cell 3"); + + expect(afterTable.bbox.y).toBeLessThan(bodyCell.bbox.y); + }); + + it("preserves searchable text when mixing auto and star columns", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + pdf.drawTable( + page, + { + columns: [ + { key: "id", width: "auto" }, + { key: "name", width: "*" }, + { key: "status", width: "auto" }, + ], + head: [["ID", "Name", "Status"]], + body: [ + ["1", "Short name", "Active"], + ["2", "A longer product name that takes more space", "Inactive"], + ["3", "Medium name here", "Pending"], + ], + }, + { + bounds: { x: 48, y: 500, width: 516, height: 300 }, + style: { fontSize: 10, lineHeight: 13, padding: 5 }, + headStyle: { font: "Helvetica-Bold" }, + }, + ); + + const bytes = await pdf.save(); + await saveTablePdf("auto-star-widths", bytes); + + const reloaded = await PDF.load(bytes); + expect(reloaded.getPageCount()).toBe(1); + + const text = reloaded.getPage(0)!.extractText().text; + expect(text).toContain("Short name"); + expect(text).toContain("A longer product name that takes more space"); + expect(text).toContain("Inactive"); + expect(text).toContain("Pending"); + }); + + it("keeps rowCountDrawn stable when a body row is split across pages", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + const result = pdf.drawTable( + page, + { + columns: [{ key: "a", width: 100 }], + body: [["short"], [Array(100).fill("word").join(" ")]], + }, + { + bounds: { x: 48, y: 72, width: 100, height: 40 }, + style: { fontSize: 12, lineHeight: 14.4, padding: 4 }, + }, + ); + + expect(result.usedPages.length).toBeGreaterThan(1); + expect(result.rowCountDrawn).toBe(2); + + const bytes = await pdf.save(); + await saveTablePdf("split-row-count", bytes); + + const reloaded = await PDF.load(bytes); + expect(reloaded.getPageCount()).toBe(result.usedPages.length); + expect( + reloaded + .extractText() + .map(pageText => pageText.text) + .join("\n"), + ).toContain("short"); + }); +}); diff --git a/src/tables/types.ts b/src/tables/types.ts new file mode 100644 index 0000000..d59f027 --- /dev/null +++ b/src/tables/types.ts @@ -0,0 +1,159 @@ +import type { Rectangle } from "#src/api/pdf-page"; +import type { FontInput } from "#src/drawing/types"; +import type { Color } from "#src/helpers/colors"; + +// ───────────────────────────────────────────────────────────────────────────── +// Public API Types +// ───────────────────────────────────────────────────────────────────────────── + +export type TableWidth = number | "auto" | "*" | { star: number }; +export type TableRepeat = "none" | "firstPage" | "everyPage" | "lastPage"; +export type TableOverflow = "wrap" | "ellipsis" | "clip"; +export type TableOverflowWrap = "word" | "break-word"; +export type TableHAlign = "left" | "center" | "right"; +export type TableVAlign = "top" | "middle" | "bottom"; + +export interface TableColumn { + key: string; + width?: TableWidth; + minWidth?: number; + maxWidth?: number; + align?: TableHAlign; + style?: Partial; +} + +export interface TableCell { + text: string; + align?: TableHAlign; + valign?: TableVAlign; + overflow?: TableOverflow; + overflowWrap?: TableOverflowWrap; + style?: Partial; +} + +export interface TableSparseRow { + cells: Record; + keepTogether?: boolean; + style?: Partial; +} + +export interface TableFullRow { + cells: readonly (string | TableCell)[]; + keepTogether?: boolean; + style?: Partial; +} + +export type TableRow = readonly (string | TableCell)[] | TableFullRow | TableSparseRow; + +export interface TableDefinition { + columns: readonly TableColumn[]; + head?: readonly TableRow[]; + body: readonly TableRow[]; + foot?: readonly TableRow[]; +} + +export interface TableCellStyle { + font: FontInput; + fontSize: number; + lineHeight: number; + textColor: Color; + fillColor?: Color; + padding: number | { top?: number; right?: number; bottom?: number; left?: number }; + align: TableHAlign; + valign: TableVAlign; + overflow: TableOverflow; + overflowWrap: TableOverflowWrap; +} + +export interface DrawTableOptions { + bounds: Rectangle; + headRepeat?: Extract; + footRepeat?: Extract; + style?: Partial; + headStyle?: Partial; + bodyStyle?: Partial; + footStyle?: Partial; + alternateRowStyle?: Partial; + columnStyles?: Record>; + outerBorderWidth?: number; + outerBorderColor?: Color; + innerBorderWidth?: number; + innerBorderColor?: Color; +} + +export interface DrawTableResult { + lastPage: import("#src/api/pdf-page").PDFPage; + usedPages: import("#src/api/pdf-page").PDFPage[]; + cursorY: number; + rowCountDrawn: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internal Normalized Types +// ───────────────────────────────────────────────────────────────────────────── + +export type Section = "head" | "body" | "foot"; + +export interface ResolvedPadding { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface ResolvedCellStyle { + font: FontInput; + fontSize: number; + lineHeight: number; + textColor: Color; + fillColor: Color | undefined; + padding: ResolvedPadding; + align: TableHAlign; + valign: TableVAlign; + overflow: TableOverflow; + overflowWrap: TableOverflowWrap; +} + +export interface NormalizedCell { + text: string; + style: ResolvedCellStyle; +} + +export interface NormalizedRow { + cells: NormalizedCell[]; + section: Section; + keepTogether: boolean; + bodyIndex: number; +} + +export interface NormalizedTable { + columns: TableColumn[]; + head: NormalizedRow[]; + body: NormalizedRow[]; + foot: NormalizedRow[]; +} + +export interface MeasuredCell { + text: string; + style: ResolvedCellStyle; + lines: { text: string; width: number }[]; + contentWidth: number; + contentHeight: number; + cellHeight: number; +} + +export interface MeasuredRow { + cells: MeasuredCell[]; + section: Section; + keepTogether: boolean; + bodyIndex: number; + rowHeight: number; +} + +export interface PageFragment { + rows: MeasuredRow[]; + headRows: MeasuredRow[]; + footRows: MeasuredRow[]; + isFirstPage: boolean; + isLastPage: boolean; +} From be6371fe57ed83e25eb959a4412deb82ab2d355a Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Sat, 7 Mar 2026 13:32:24 +0000 Subject: [PATCH 2/2] chore: remove redundant comments and dead code from table module --- src/api/pdf.ts | 4 ---- src/tables/measure.ts | 13 ------------- src/tables/normalize.ts | 2 +- src/tables/style.ts | 11 +---------- src/tables/types.ts | 10 ---------- 5 files changed, 2 insertions(+), 38 deletions(-) diff --git a/src/api/pdf.ts b/src/api/pdf.ts index 2707131..5d1d2f1 100644 --- a/src/api/pdf.ts +++ b/src/api/pdf.ts @@ -2067,10 +2067,6 @@ export class PDF { return this.fonts.getRef(font); } - // ───────────────────────────────────────────────────────────────────────────── - // Image Embedding - // ───────────────────────────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────────────────────────── // Table Drawing // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/tables/measure.ts b/src/tables/measure.ts index 5a301f7..f5d8d11 100644 --- a/src/tables/measure.ts +++ b/src/tables/measure.ts @@ -10,10 +10,6 @@ import type { TableColumn, } from "./types"; -// ───────────────────────────────────────────────────────────────────────────── -// Text breaking and overflow -// ───────────────────────────────────────────────────────────────────────────── - interface TextLine { text: string; width: number; @@ -41,7 +37,6 @@ export function layoutCellText( return ellipsisText(text, font, fontSize, maxWidth); } - // overflow === "wrap" const paragraphs = text.split(/\r\n|\r|\n/); const lines: TextLine[] = []; @@ -204,10 +199,6 @@ function ellipsisText( return [{ text: clipped + ellipsis, width: width + ellipsisWidth }]; } -// ───────────────────────────────────────────────────────────────────────────── -// Cell and row measurement -// ───────────────────────────────────────────────────────────────────────────── - function measureCell( cell: { text: string; style: ResolvedCellStyle }, columnWidth: number, @@ -242,10 +233,6 @@ export function measureRow(row: NormalizedRow, columnWidths: number[]): Measured }; } -// ───────────────────────────────────────────────────────────────────────────── -// Column width resolution -// ───────────────────────────────────────────────────────────────────────────── - export function resolveColumnWidths(table: NormalizedTable, availableWidth: number): number[] { const { columns } = table; const widths = new Array(columns.length).fill(0); diff --git a/src/tables/normalize.ts b/src/tables/normalize.ts index 2c7bc25..900b8b3 100644 --- a/src/tables/normalize.ts +++ b/src/tables/normalize.ts @@ -50,7 +50,7 @@ function extractCells(row: TableRow, columns: readonly TableColumn[]): (string | if (isFullRow(row)) { return row.cells as (string | TableCell)[]; } - return row as (string | TableCell)[]; + throw new Error("Unknown row type"); } function extractRowMeta(row: TableRow): { diff --git a/src/tables/style.ts b/src/tables/style.ts index 476d2f9..3aada28 100644 --- a/src/tables/style.ts +++ b/src/tables/style.ts @@ -84,11 +84,7 @@ export function resolveStyleCascade( ): Map { const styles = new Map(); - // Level 1: defaults - let base = { ...DEFAULT_STYLE }; - - // Level 2: table-wide style - base = mergeStyle(base, options.style); + let base = mergeStyle({ ...DEFAULT_STYLE }, options.style); const sectionStyleMap: Record | undefined> = { head: options.headStyle, @@ -103,29 +99,24 @@ export function resolveStyleCascade( ]; for (const { name: section, rows } of sections) { - // Level 3: section style const sectionBase = mergeStyle(base, sectionStyleMap[section]); for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { - // Level 4: alternate row style (body only) let rowBase = sectionBase; if (section === "body" && options.alternateRowStyle && rowIdx % 2 === 1) { rowBase = mergeStyle(sectionBase, options.alternateRowStyle); } for (const col of columns) { - // Level 5: column style let cellBase = rowBase; if (options.columnStyles?.[col.key]) { cellBase = mergeStyle(rowBase, options.columnStyles[col.key]); } - // Level 5.5: column-level align from TableColumn if (col.align) { cellBase = { ...cellBase, align: col.align }; } - // Column-level style from TableColumn.style if (col.style) { cellBase = mergeStyle(cellBase, col.style); } diff --git a/src/tables/types.ts b/src/tables/types.ts index d59f027..1848bb3 100644 --- a/src/tables/types.ts +++ b/src/tables/types.ts @@ -1,11 +1,6 @@ import type { Rectangle } from "#src/api/pdf-page"; import type { FontInput } from "#src/drawing/types"; import type { Color } from "#src/helpers/colors"; - -// ───────────────────────────────────────────────────────────────────────────── -// Public API Types -// ───────────────────────────────────────────────────────────────────────────── - export type TableWidth = number | "auto" | "*" | { star: number }; export type TableRepeat = "none" | "firstPage" | "everyPage" | "lastPage"; export type TableOverflow = "wrap" | "ellipsis" | "clip"; @@ -87,11 +82,6 @@ export interface DrawTableResult { cursorY: number; rowCountDrawn: number; } - -// ───────────────────────────────────────────────────────────────────────────── -// Internal Normalized Types -// ───────────────────────────────────────────────────────────────────────────── - export type Section = "head" | "body" | "foot"; export interface ResolvedPadding {