diff --git a/demo/table_demo.nim b/demo/table_demo.nim new file mode 100644 index 0000000..3662481 --- /dev/null +++ b/demo/table_demo.nim @@ -0,0 +1,483 @@ +# Table demo for nimPDF +# Comprehensive demonstration of table features + +import ../nimPDF/nimPDF + +proc addDescription(page: Page, title: string, description: seq[string], y: var float64) = + ## Helper to add a description section on the current page + # Font sizes are in MM in nimPDF by default + page.setFont("Helvetica", {FS_BOLD}, 4.5) # ~14pt + page.drawText(10, y, title) + + y += 7.0 + page.setFont("Helvetica", {FS_REGULAR}, 3.2) # ~10pt + + for line in description: + page.drawText(10, y, line) + y += 4.5 + + y += 3.0 # Extra spacing after description + +proc main() = + var doc = newPDF() + var page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + + # Title page - Professional styling + page.setFont("Helvetica", {FS_BOLD}, 7) # ~20pt + page.drawText(20, 80, "Table System Documentation") + page.setFont("Helvetica", {FS_REGULAR}, 4) # ~12pt + page.drawText(20, 92, "Feature Demonstrations and Usage Examples") + page.setFont("Helvetica", {FS_REGULAR}, 3) # ~9pt + + # Start examples on new page + page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + page.setFont("Helvetica", {FS_REGULAR}, 8) # Smaller default font for tables + + var currentY = 15.0 + let spacing = 8.0 # Space between sections + + # Example 1: Quick table with drawSimpleTable + echo "Creating quick simple table..." + addDescription(page, "Example 1: Quick Tables with drawSimpleTable()", @[ + "The fastest way to create a table from a 2D array.", + "Automatically handles page wrapping and column distribution." + ], currentY) + + let quickData = @[ + @["Product", "Price", "Stock"], + @["Laptop", "$999", "15"], + @["Mouse", "$25", "50"], + @["Keyboard", "$75", "30"] + ] + + let dims1 = page.drawSimpleTable(doc, currentY, quickData, hasHeader = true, maxWidth = 190, spacing = spacing) + + # Example 2: Basic table with manual construction + echo "Creating basic table..." + if currentY > 220: + page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + page.setFont("Helvetica", {FS_REGULAR}, 8) + currentY = 15.0 + + addDescription(page, "Example 2: Manual Table Construction", @[ + "Build tables row-by-row for more control over content.", + "All tables automatically wrap to new pages when needed." + ], currentY) + + var basicTable = newTable(10, currentY, autoFit = true) + basicTable.addHeaderRow("Department", "Manager", "Location", "Status") + basicTable.addDataRow("Sales", "Alice Johnson", "New York", "Active") + basicTable.addDataRow("Engineering", "Bob Smith", "London", "Active") + basicTable.addDataRow("Marketing", "Charlie Brown", "Paris", "Active") + + let dims2 = basicTable.draw(page, doc, currentY, 190, spacing) + + # Example 3: Custom styled table + echo "Creating custom styled table..." + if currentY > 220: # Check if we need a new page + page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + page.setFont("Helvetica", {FS_REGULAR}, 8) + currentY = 15.0 + + addDescription(page, "Example 3: Professional Styling & Merged Cells", @[ + "Corporate styling with custom colors, borders, and cell spanning.", + "Demonstrates professional invoice/report layouts." + ], currentY) + + var customTable = newTable(10, currentY, autoFit = true) + + # Professional business styling - more subdued colors + customTable.defaultCellStyle.backgroundColor = initRGB(1.0, 1.0, 1.0) + customTable.defaultCellStyle.padding = (left: 4.0, right: 4.0, top: 2.5, bottom: 2.5) + customTable.defaultCellStyle.border = initCellBorder(0.5, initRGB(0.6, 0.6, 0.6)) + + customTable.headerStyle.backgroundColor = initRGB(0.25, 0.35, 0.55) + customTable.headerStyle.textColor = initRGB(1.0, 1.0, 1.0) + customTable.headerStyle.border = initCellBorder(0.5, initRGB(0.2, 0.3, 0.5)) + + # Add header + customTable.addHeaderRow("Product", "Price", "Quantity", "Total") + + # Add data rows + customTable.addDataRow("Widget A", "$19.99", "5", "$99.95") + customTable.addDataRow("Widget B", "$24.50", "3", "$73.50") + customTable.addDataRow("Widget C", "$15.00", "10", "$150.00") + + # Add a total row with custom style, merged cells, and bold font + var totalStyle = initCellStyle() + totalStyle.backgroundColor = initRGB(0.92, 0.92, 0.92) + totalStyle.horizontalAlign = ALIGN_RIGHT + totalStyle.verticalAlign = ALIGN_MIDDLE + totalStyle.border = initCellBorder(1.0, initRGB(0.4, 0.4, 0.4)) + totalStyle.fontBold = true + totalStyle.fontSize = 6 + + # Create a cell that spans 3 columns for "Total:" label + var totalLabelCell = newCell("Total:", totalStyle) + totalLabelCell.colspan = 3 + + var totalRow = newRow( + totalLabelCell, + newCell("$323.45", totalStyle) + ) + customTable.addRow(totalRow) + + let dims3 = customTable.draw(page, doc, currentY, 190, spacing) + + # Example 4: Table with different alignments + echo "Creating alignment demo table..." + # Start Example 4 on a fresh page to keep heading, description, and table together + page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + page.setFont("Helvetica", {FS_REGULAR}, 8) + currentY = 15.0 + + addDescription(page, "Example 4: Text Alignment Options", @[ + "Each cell shows text positioned at that alignment.", + "Demonstrates all 9 combinations of horizontal and vertical alignment." + ], currentY) + + var alignTable = newTable(10, currentY, autoFit = true) + + # Header + alignTable.addHeaderRow("Left", "Center", "Right") + + # Define all 9 alignment styles with moderate padding to show positioning clearly + var topLeft = initCellStyle() + topLeft.horizontalAlign = ALIGN_LEFT + topLeft.verticalAlign = ALIGN_TOP + topLeft.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + var topCenter = initCellStyle() + topCenter.horizontalAlign = ALIGN_CENTER + topCenter.verticalAlign = ALIGN_TOP + topCenter.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + var topRight = initCellStyle() + topRight.horizontalAlign = ALIGN_RIGHT + topRight.verticalAlign = ALIGN_TOP + topRight.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + var middleLeft = initCellStyle() + middleLeft.horizontalAlign = ALIGN_LEFT + middleLeft.verticalAlign = ALIGN_MIDDLE + middleLeft.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + var middleCenter = initCellStyle() + middleCenter.horizontalAlign = ALIGN_CENTER + middleCenter.verticalAlign = ALIGN_MIDDLE + middleCenter.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + var middleRight = initCellStyle() + middleRight.horizontalAlign = ALIGN_RIGHT + middleRight.verticalAlign = ALIGN_MIDDLE + middleRight.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + var bottomLeft = initCellStyle() + bottomLeft.horizontalAlign = ALIGN_LEFT + bottomLeft.verticalAlign = ALIGN_BOTTOM + bottomLeft.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + var bottomCenter = initCellStyle() + bottomCenter.horizontalAlign = ALIGN_CENTER + bottomCenter.verticalAlign = ALIGN_BOTTOM + bottomCenter.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + var bottomRight = initCellStyle() + bottomRight.horizontalAlign = ALIGN_RIGHT + bottomRight.verticalAlign = ALIGN_BOTTOM + bottomRight.padding = (left: 3.0, right: 3.0, top: 2.0, bottom: 2.0) + + # Top row - set minHeight to make vertical alignment visible + var topRow = newRow( + newCell("Top-Left", topLeft), + newCell("Top-Center", topCenter), + newCell("Top-Right", topRight) + ) + topRow.minHeight = 30.0 + alignTable.addRow(topRow) + + # Middle row + var middleRow = newRow( + newCell("Middle-Left", middleLeft), + newCell("Middle-Center", middleCenter), + newCell("Middle-Right", middleRight) + ) + middleRow.minHeight = 30.0 + alignTable.addRow(middleRow) + + # Bottom row + var bottomRow = newRow( + newCell("Bottom-Left", bottomLeft), + newCell("Bottom-Center", bottomCenter), + newCell("Bottom-Right", bottomRight) + ) + bottomRow.minHeight = 30.0 + alignTable.addRow(bottomRow) + + let dims4 = alignTable.draw(page, doc, currentY, 190, spacing) + + # Example 5: Table with different fonts per cell + echo "Creating font demo table..." + if currentY > 220: + page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + page.setFont("Helvetica", {FS_REGULAR}, 8) + currentY = 15.0 + + addDescription(page, "Example 5: Font Customization", @[ + "Per-cell font control for emphasis and readability.", + "Supports Helvetica, Times, Courier with bold and italic styles." + ], currentY) + + var fontTable = newTable(10, currentY, autoFit = true) + + fontTable.addHeaderRow("Font", "Style", "Example") + + # Regular font + var regularStyle = initCellStyle() + fontTable.addRow(newRow( + newCell("Helvetica", regularStyle), + newCell("Regular", regularStyle), + newCell("The quick brown fox", regularStyle) + )) + + # Bold font + var boldStyle = initCellStyle() + boldStyle.fontBold = true + fontTable.addRow(newRow( + newCell("Helvetica", regularStyle), + newCell("Bold", regularStyle), + newCell("The quick brown fox", boldStyle) + )) + + # Italic font + var italicStyle = initCellStyle() + italicStyle.fontItalic = true + fontTable.addRow(newRow( + newCell("Helvetica", regularStyle), + newCell("Italic", regularStyle), + newCell("The quick brown fox", italicStyle) + )) + + # Large font + var largeStyle = initCellStyle() + largeStyle.fontSize = 7.0 # ~20pt + largeStyle.fontBold = true + fontTable.addRow(newRow( + newCell("Helvetica", regularStyle), + newCell("Large Bold", regularStyle), + newCell("The quick brown fox", largeStyle) + )) + + # Times font + var timesStyle = initCellStyle() + timesStyle.fontFamily = "Times" + fontTable.addRow(newRow( + newCell("Times", regularStyle), + newCell("Regular", regularStyle), + newCell("The quick brown fox", timesStyle) + )) + + # Courier font + var courierStyle = initCellStyle() + courierStyle.fontFamily = "Courier" + fontTable.addRow(newRow( + newCell("Courier", regularStyle), + newCell("Regular", regularStyle), + newCell("The quick brown fox", courierStyle) + )) + + let dims5 = fontTable.draw(page, doc, currentY, 190, spacing) + + # Example 6: Financial report table with page wrapping + echo "Creating financial report table..." + if currentY > 180: + page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + page.setFont("Helvetica", {FS_REGULAR}, 8) + currentY = 15.0 + + addDescription(page, "Example 6: Financial Reports with Auto-Pagination", @[ + "Quarterly financial data with automatic page breaks.", + "Headers repeat on new pages for multi-page reports." + ], currentY) + + var reportTable = newTable(10, currentY, autoFit = true) + + # Professional financial report styling + reportTable.headerStyle.backgroundColor = initRGB(0.2, 0.3, 0.5) + reportTable.headerStyle.textColor = initRGB(1.0, 1.0, 1.0) + reportTable.defaultCellStyle.padding = (left: 5.0, right: 5.0, top: 2.5, bottom: 2.5) + + reportTable.addHeaderRow("Period", "Revenue", "Expenses", "Net Profit", "Margin %") + + # Add multiple years of quarterly data to demonstrate pagination + reportTable.addDataRow("Q1 2021", "$98,450", "$72,300", "$26,150", "26.6%") + reportTable.addDataRow("Q2 2021", "$105,200", "$78,900", "$26,300", "25.0%") + reportTable.addDataRow("Q3 2021", "$112,800", "$82,400", "$30,400", "27.0%") + reportTable.addDataRow("Q4 2021", "$118,500", "$86,200", "$32,300", "27.3%") + + reportTable.addDataRow("Q1 2022", "$125,000", "$87,000", "$38,000", "30.4%") + reportTable.addDataRow("Q2 2022", "$132,500", "$91,200", "$41,300", "31.2%") + reportTable.addDataRow("Q3 2022", "$138,900", "$94,800", "$44,100", "31.7%") + reportTable.addDataRow("Q4 2022", "$145,200", "$98,400", "$46,800", "32.2%") + + reportTable.addDataRow("Q1 2023", "$152,000", "$102,000", "$50,000", "32.9%") + reportTable.addDataRow("Q2 2023", "$158,500", "$105,200", "$53,300", "33.6%") + reportTable.addDataRow("Q3 2023", "$165,900", "$108,800", "$57,100", "34.4%") + reportTable.addDataRow("Q4 2023", "$172,200", "$112,400", "$59,800", "34.7%") + + reportTable.addDataRow("Q1 2024", "$180,000", "$115,000", "$65,000", "36.1%") + reportTable.addDataRow("Q2 2024", "$187,500", "$118,200", "$69,300", "37.0%") + reportTable.addDataRow("Q3 2024", "$195,900", "$121,800", "$74,100", "37.8%") + reportTable.addDataRow("Q4 2024", "$203,200", "$125,400", "$77,800", "38.3%") + + reportTable.addDataRow("Q1 2025", "$210,000", "$128,000", "$82,000", "39.0%") + reportTable.addDataRow("Q2 2025", "$218,500", "$131,200", "$87,300", "40.0%") + reportTable.addDataRow("Q3 2025", "$226,900", "$134,800", "$92,100", "40.6%") + reportTable.addDataRow("Q4 2025", "$235,200", "$138,400", "$96,800", "41.2%") + + # Draw with automatic page wrapping - will span multiple pages + let dims6 = reportTable.draw(page, doc, currentY, 190, spacing) + + # Example 7: Table with text wrapping + echo "Creating text wrapping demo table..." + # draw() already positioned us correctly, just check if we need more space + if currentY > 220: + page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + page.setFont("Helvetica", {FS_REGULAR}, 8) + currentY = 15.0 + + addDescription(page, "Example 7: Automatic Text Wrapping", @[ + "Long text content wraps automatically within cells.", + "Row heights adjust dynamically to fit multi-line content." + ], currentY) + + var wrapTable = newTable(10, currentY, autoFit = true) + + wrapTable.headerStyle.backgroundColor = initRGB(0.25, 0.35, 0.55) + wrapTable.headerStyle.textColor = initRGB(1.0, 1.0, 1.0) + + wrapTable.addHeaderRow("Feature", "Description", "Status") + wrapTable.addDataRow( + "Text Wrapping", + "Automatically wraps long text content within table cells to fit the available column width without overflow", + "Active" + ) + wrapTable.addDataRow( + "Auto-fit Columns", + "Distributes available space proportionally across columns based on content requirements", + "Active" + ) + wrapTable.addDataRow( + "Custom Styling", + "Supports custom colors, borders, padding, and alignment options for individual cells or entire tables", + "Active" + ) + + # Draw with automatic page wrapping + let dims7 = wrapTable.draw(page, doc, currentY, 190, spacing) + + # Example 8: Auto-sizing demonstration + echo "Creating auto-sizing demo table..." + # draw() already positioned us correctly + if currentY > 220: + page = doc.addPage(getSizeFromName("A4"), PGO_PORTRAIT) + page.setFont("Helvetica", {FS_REGULAR}, 8) + currentY = 15.0 + + addDescription(page, "Example 8: Smart Font Sizing", @[ + "Automatic font scaling prevents text overflow while maintaining readability.", + "Auto-shrink mode and scale-to-fit options with minimum size enforcement.", + "The middle column is intentionally narrow to demonstrate the scaling effect." + ], currentY) + + var autoSizeTable = newTable(10, currentY, autoFit = true) + + autoSizeTable.headerStyle.backgroundColor = initRGB(0.25, 0.35, 0.55) + autoSizeTable.headerStyle.textColor = initRGB(1.0, 1.0, 1.0) + + autoSizeTable.addHeaderRow("Mode", "Example Text", "Behavior") + + # Regular mode with overflow (text will auto-shrink) + autoSizeTable.addDataRow( + "Auto-shrink", + "ThisIsAnExtremelyLongWordThatDefinitelyWillNotFitInTheCellWithoutScaling", + "Font size reduced automatically" + ) + + # Another auto-shrink example + autoSizeTable.addDataRow( + "Auto-shrink", + "Pneumonoultramicroscopicsilicovolcanoconiosis", + "Longest English word shrinks to fit" + ) + + # ScaleToFit mode - keeps text on one line + var scaleStyle = initCellStyle() + scaleStyle.scaleToFit = true + scaleStyle.fontFamily = "Courier" + autoSizeTable.addRow(newRow( + newCell("Scale-to-fit", autoSizeTable.defaultCellStyle), + newCell("The quick brown fox jumps over the lazy dog and runs through the forest", scaleStyle), + newCell("Scaled horizontally to single line", autoSizeTable.defaultCellStyle) + )) + + # ScaleToFit with minimum font size + var scaleWithMinStyle = initCellStyle() + scaleWithMinStyle.scaleToFit = true + scaleWithMinStyle.minFontSize = 6.0 + scaleWithMinStyle.fontFamily = "Times" + autoSizeTable.addRow(newRow( + newCell("Scale w/ 6pt min", autoSizeTable.defaultCellStyle), + newCell("This is a very long text that will scale down but will not go below 6pt minimum font size", scaleWithMinStyle), + newCell("Enforces 6pt minimum", autoSizeTable.defaultCellStyle) + )) + + # ScaleToFit with tiny minimum + var tinyMinStyle = initCellStyle() + tinyMinStyle.scaleToFit = true + tinyMinStyle.minFontSize = 3.0 + autoSizeTable.addRow(newRow( + newCell("Scale w/ 3pt min", autoSizeTable.defaultCellStyle), + newCell("This is an extremely long text that will scale to a very tiny size with only 3pt minimum allowing much smaller text", tinyMinStyle), + newCell("Can become very small", autoSizeTable.defaultCellStyle) + )) + + # Multi-line with scaleToFit (respects \n) + var multiLineStyle = initCellStyle() + multiLineStyle.scaleToFit = true + autoSizeTable.addRow(newRow( + newCell("Multi-line scale", autoSizeTable.defaultCellStyle), + newCell("Line 1: First line\nLine 2: Second line\nLine 3: Third line", multiLineStyle), + newCell("Respects \\n, scales each line", autoSizeTable.defaultCellStyle) + )) + + # Bold with scale-to-fit + var boldScaleStyle = initCellStyle() + boldScaleStyle.scaleToFit = true + boldScaleStyle.fontBold = true + autoSizeTable.addRow(newRow( + newCell("Bold + scale", autoSizeTable.defaultCellStyle), + newCell("Bold text also scales: The quick brown fox", boldScaleStyle), + newCell("Bold fonts supported", autoSizeTable.defaultCellStyle) + )) + + # Italic with scale-to-fit + var italicScaleStyle = initCellStyle() + italicScaleStyle.scaleToFit = true + italicScaleStyle.fontItalic = true + italicScaleStyle.fontFamily = "Helvetica" + autoSizeTable.addRow(newRow( + newCell("Italic + scale", autoSizeTable.defaultCellStyle), + newCell("Italic text scales too: The quick brown fox jumps", italicScaleStyle), + newCell("Italic fonts supported", autoSizeTable.defaultCellStyle) + )) + + # Use narrower width (140mm instead of 190mm) to force more aggressive scaling + let dims8 = autoSizeTable.draw(page, doc, currentY, 140, spacing) + + # Save the document + echo "Saving table_demo.pdf..." + discard doc.writePDF("table_demo.pdf") + echo "Done! Created table_demo.pdf" + +main() diff --git a/demo/table_demo.pdf b/demo/table_demo.pdf new file mode 100644 index 0000000..71afc26 Binary files /dev/null and b/demo/table_demo.pdf differ diff --git a/nimPDF/nimPDF.nim b/nimPDF/nimPDF.nim index e869c95..4af4840 100644 --- a/nimPDF/nimPDF.nim +++ b/nimPDF/nimPDF.nim @@ -9,9 +9,11 @@ import streams, basic2d import image, gstate, path, fontmanager import encryptdict, encrypt, page, options, widgets +import table export encryptdict.DocInfo, encrypt.EncryptMode, widgets export path, gstate, image, fontmanager, page, options +export table const nimPDFVersion = "0.4.3" diff --git a/nimPDF/page.nim b/nimPDF/page.nim index 9b3cc93..9dff1eb 100644 --- a/nimPDF/page.nim +++ b/nimPDF/page.nim @@ -84,7 +84,7 @@ type images: seq[Image] gradients: seq[Gradient] fontMan: FontManager - gState: GState + gState*: GState pathStartX, pathStartY, pathEndX, pathEndY: float64 recordShape: bool shapes: seq[Path] @@ -645,7 +645,7 @@ proc setFontStyle*(a: AcroForm, style: FontStyles) = proc setEncoding*(a: AcroForm, enc: EncodingType) = a.encoding = enc -proc put(self: ContentBase, text: varargs[string]) = +proc put*(self: ContentBase, text: varargs[string]) = for s in items(text): self.content.add(s) self.content.add('\x0A') diff --git a/nimPDF/table.nim b/nimPDF/table.nim new file mode 100644 index 0000000..f8c3d08 --- /dev/null +++ b/nimPDF/table.nim @@ -0,0 +1,751 @@ +#----------------------------------------- +# Table support for nimPDF added By Duncan Clarke +# Support for auto fit, shrink to fit, styles, +# auto new pages with optional duplicated headers +# for long tables. + +import gstate, page, fontmanager +import strutils, math + +type + CellAlignment* = enum + ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT, ALIGN_TOP, ALIGN_MIDDLE, ALIGN_BOTTOM + + CellBorder* = object + left*, right*, top*, bottom*: bool + width*: float64 + color*: RGBColor + + CellStyle* = object + backgroundColor*: RGBColor + textColor*: RGBColor + border*: CellBorder + padding*: tuple[left, right, top, bottom: float64] + horizontalAlign*: CellAlignment + verticalAlign*: CellAlignment + fontFamily*: string + fontSize*: float64 + fontBold*: bool + fontItalic*: bool + scaleToFit*: bool # If true, scale text to fit without wrapping + minFontSize*: float64 # Minimum font size for auto-shrinking (0 = no limit) + + Cell* = ref object + content*: string + style*: CellStyle + colspan*: int + rowspan*: int + width*: float64 # Calculated width + height*: float64 # Calculated height + wrappedLines*: seq[string] # Text wrapped into multiple lines + actualFontSize*: float64 # Actual font size used after auto-sizing + + Row* = ref object + cells*: seq[Cell] + height*: float64 + minHeight*: float64 + + Table* = ref object + rows*: seq[Row] + columnWidths*: seq[float64] + totalWidth*: float64 + x*, y*: float64 + defaultCellStyle*: CellStyle + headerStyle*: CellStyle + autoFit*: bool + +proc initCellBorder*(width: float64 = 0.5, color: RGBColor = initRGB(0, 0, 0)): CellBorder = + result.left = true + result.right = true + result.top = true + result.bottom = true + result.width = width + result.color = color + +proc initCellBorder*(left, right, top, bottom: bool, width: float64 = 0.5, + color: RGBColor = initRGB(0, 0, 0)): CellBorder = + result.left = left + result.right = right + result.top = top + result.bottom = bottom + result.width = width + result.color = color + +proc initCellStyle*(): CellStyle = + result.backgroundColor = initRGB(1.0, 1.0, 1.0) + result.textColor = initRGB(0, 0, 0) + result.border = initCellBorder() + result.padding = (left: 2.0, right: 2.0, top: 2.0, bottom: 2.0) + result.horizontalAlign = ALIGN_LEFT + result.verticalAlign = ALIGN_MIDDLE + result.fontFamily = "" # Empty means use page default + result.fontSize = 0.0 # 0 means use page default + result.fontBold = false + result.fontItalic = false + result.scaleToFit = false + result.minFontSize = 0.0 # No minimum by default + +proc initHeaderStyle*(): CellStyle = + result = initCellStyle() + result.backgroundColor = initRGB(0.9, 0.9, 0.9) + result.textColor = initRGB(0, 0, 0) + result.horizontalAlign = ALIGN_CENTER + result.verticalAlign = ALIGN_MIDDLE + +proc newCell*(content: string, style: CellStyle = initCellStyle(), + colspan: int = 1, rowspan: int = 1): Cell = + new(result) + result.content = content + result.style = style + result.colspan = colspan + result.rowspan = rowspan + result.width = 0.0 + result.height = 0.0 + result.actualFontSize = 0.0 + +proc newRow*(cells: varargs[Cell]): Row = + new(result) + result.cells = @cells + result.height = 0.0 + result.minHeight = 0.0 + +proc newTable*(x, y: float64, autoFit: bool = true): Table = + new(result) + result.rows = @[] + result.columnWidths = @[] + result.totalWidth = 0.0 + result.x = x + result.y = y + result.defaultCellStyle = initCellStyle() + result.headerStyle = initHeaderStyle() + result.autoFit = autoFit + +proc addRow*(table: Table, row: Row) = + table.rows.add(row) + +proc addHeaderRow*(table: Table, headers: varargs[string]) = + var cells: seq[Cell] = @[] + for header in headers: + cells.add(newCell(header, table.headerStyle)) + table.addRow(newRow(cells)) + +proc addDataRow*(table: Table, data: varargs[string]) = + var cells: seq[Cell] = @[] + for item in data: + cells.add(newCell(item, table.defaultCellStyle)) + table.addRow(newRow(cells)) + +proc measureText(page: ContentBase, text: string): tuple[width, height: float64] = + # Use the page's built-in text measurement + let width = page.getTextWidth(text) + let height = page.getTextHeight(text) + result = (width: width, height: height) + +proc wrapText(text: string, maxWidth: float64, page: ContentBase): seq[string] = + # Word wrapping algorithm with proper width checking + result = @[] + if text.len == 0: + return @[""] + + # Split into words + var words = text.split(' ') + var currentLine = "" + + for i, word in words: + # Build test line + let testLine = if currentLine.len > 0: currentLine & " " & word else: word + let testWidth = page.getTextWidth(testLine) + + if testWidth <= maxWidth: + # Fits on current line + currentLine = testLine + else: + # Doesn't fit + if currentLine.len > 0: + # Save current line and start new one with this word + result.add(currentLine) + currentLine = word + else: + # Even single word doesn't fit - force it anyway + result.add(word) + currentLine = "" + + # Add remaining text + if currentLine.len > 0: + result.add(currentLine) + + if result.len == 0: + result.add("") + +proc calculateColumnWidths*(table: Table, page: ContentBase, maxWidth: float64) = + if table.rows.len == 0: + return + + # Find max number of columns + var numColumns = 0 + for row in table.rows: + if row.cells.len > numColumns: + numColumns = row.cells.len + + if numColumns == 0: + return + + # Initialize column widths array + table.columnWidths = newSeq[float64](numColumns) + + # CRITICAL: Enforce absolute maximum width + # Units are typically in MM (nimPDF default) + # A4 is 210mm wide, safe area is about 190mm (210 - 20 margin) + let effectiveMaxWidth = if maxWidth > 0 and maxWidth < 210.0: maxWidth else: 150.0 + + # Calculate minimum width per column based on longest single word + var minWidths = newSeq[float64](numColumns) + for i in 0 ..< numColumns: + minWidths[i] = 20.0 # Absolute bare minimum + + # Find minimum required width for each column + for row in table.rows: + for i, cell in row.cells: + if i < numColumns and cell.colspan == 1 and cell.content.len > 0: + let words = cell.content.split(' ') + var maxWordWidth = 0.0 + for word in words: + let wordWidth = page.getTextWidth(word) + if wordWidth > maxWordWidth: + maxWordWidth = wordWidth + + # Minimum = longest word + padding + let minRequired = maxWordWidth + cell.style.padding.left + cell.style.padding.right + 2.0 + if minRequired > minWidths[i]: + minWidths[i] = minRequired + + # Check total minimum width needed + let totalMinWidth = minWidths.sum() + + if totalMinWidth > effectiveMaxWidth: + # We MUST compress - scale everything down proportionally + let compressionRatio = effectiveMaxWidth / totalMinWidth + for i in 0 ..< numColumns: + table.columnWidths[i] = minWidths[i] * compressionRatio * 0.98 # 98% to be safe + else: + # We have space - use equal distribution for simplicity + let equalWidth = effectiveMaxWidth / float64(numColumns) + for i in 0 ..< numColumns: + table.columnWidths[i] = equalWidth + + # Final safety check - absolutely ensure we don't exceed max + let actualTotal = table.columnWidths.sum() + if actualTotal > effectiveMaxWidth: + let safetyScale = (effectiveMaxWidth * 0.98) / actualTotal + for i in 0 ..< numColumns: + table.columnWidths[i] = table.columnWidths[i] * safetyScale + + table.totalWidth = table.columnWidths.sum() + +proc calculateRowHeights*(table: Table, page: ContentBase) = + for rowIdx, row in table.rows: + var maxHeight = 0.0 + for colIdx, cell in row.cells: + if cell.rowspan == 1 and colIdx < table.columnWidths.len: + # Calculate available width for text (accounting for padding) + var availableWidth = table.columnWidths[colIdx] - cell.style.padding.left - cell.style.padding.right + + # Handle colspan + if cell.colspan > 1: + for j in 1 ..< cell.colspan: + if colIdx + j < table.columnWidths.len: + availableWidth += table.columnWidths[colIdx + j] + + # Get base font size + let baseFontSize = if cell.style.fontSize > 0: cell.style.fontSize + else: page.toUser(page.state.gState.fontSize) + cell.actualFontSize = baseFontSize + + # Handle scaleToFit mode + if cell.style.scaleToFit: + # In scaleToFit mode, keep text on original lines (respect \n) + let lines = if "\n" in cell.content: cell.content.split('\n') else: @[cell.content] + cell.wrappedLines = lines + + # Temporarily set the cell's font to measure accurately + page.saveState() + if cell.style.fontFamily.len > 0: + var fontStyle: set[FontStyle] = {} + if cell.style.fontBold and cell.style.fontItalic: + fontStyle = {FS_BOLD, FS_ITALIC} + elif cell.style.fontBold: + fontStyle = {FS_BOLD} + elif cell.style.fontItalic: + fontStyle = {FS_ITALIC} + else: + fontStyle = {FS_REGULAR} + page.setFont(cell.style.fontFamily, fontStyle, baseFontSize) + elif baseFontSize != page.toUser(page.state.gState.fontSize): + # Just set the size if different + let savedFont = page.state.gState.font + if savedFont != nil: + page.setFont("Helvetica", {FS_REGULAR}, baseFontSize) + + # Find the longest line and calculate scale factor + var maxLineWidth = 0.0 + for line in lines: + let lineWidth = page.getTextWidth(line) + if lineWidth > maxLineWidth: + maxLineWidth = lineWidth + + page.restoreState() + + # Calculate scale factor if text is too wide + if maxLineWidth > availableWidth: + # Use 98% safety margin to ensure text definitely fits + let scaleFactor = (availableWidth * 0.98) / maxLineWidth + cell.actualFontSize = baseFontSize * scaleFactor + + # Apply minimum font size if specified + if cell.style.minFontSize > 0 and cell.actualFontSize < cell.style.minFontSize: + cell.actualFontSize = cell.style.minFontSize + + # Check if text still fits at minimum size, if not, wrap it + page.saveState() + if cell.style.fontFamily.len > 0: + var fontStyle: set[FontStyle] = {} + if cell.style.fontBold and cell.style.fontItalic: + fontStyle = {FS_BOLD, FS_ITALIC} + elif cell.style.fontBold: + fontStyle = {FS_BOLD} + elif cell.style.fontItalic: + fontStyle = {FS_ITALIC} + else: + fontStyle = {FS_REGULAR} + page.setFont(cell.style.fontFamily, fontStyle, cell.actualFontSize) + else: + page.setFont("Helvetica", {FS_REGULAR}, cell.actualFontSize) + + # Re-measure at minimum font size + maxLineWidth = 0.0 + for line in cell.wrappedLines: + let lineWidth = page.getTextWidth(line) + if lineWidth > maxLineWidth: + maxLineWidth = lineWidth + + # If still doesn't fit, wrap the text + if maxLineWidth > availableWidth: + cell.wrappedLines = wrapText(cell.content, availableWidth, page) + + page.restoreState() + + let lineHeight = page.getTextHeight("Ag") * 1.2 # 1.2x for comfortable line spacing + let requiredHeight = float64(cell.wrappedLines.len) * lineHeight + + cell.style.padding.top + cell.style.padding.bottom + + if requiredHeight > maxHeight: + maxHeight = requiredHeight + else: + # Normal wrapping mode + # Temporarily set the cell's font to measure accurately + page.saveState() + if cell.style.fontFamily.len > 0: + var fontStyle: set[FontStyle] = {} + if cell.style.fontBold and cell.style.fontItalic: + fontStyle = {FS_BOLD, FS_ITALIC} + elif cell.style.fontBold: + fontStyle = {FS_BOLD} + elif cell.style.fontItalic: + fontStyle = {FS_ITALIC} + else: + fontStyle = {FS_REGULAR} + page.setFont(cell.style.fontFamily, fontStyle, baseFontSize) + elif baseFontSize != page.toUser(page.state.gState.fontSize): + let savedFont = page.state.gState.font + if savedFont != nil: + page.setFont("Helvetica", {FS_REGULAR}, baseFontSize) + + cell.wrappedLines = wrapText(cell.content, availableWidth, page) + + # Check if any line still overflows (single word too long) + var needsAutoShrink = false + var maxOverflowRatio = 1.0 + for line in cell.wrappedLines: + let lineWidth = page.getTextWidth(line) + if lineWidth > availableWidth: + needsAutoShrink = true + let ratio = lineWidth / availableWidth + if ratio > maxOverflowRatio: + maxOverflowRatio = ratio + + page.restoreState() + + # Auto-shrink if needed + if needsAutoShrink: + # Use 98% safety margin to ensure text definitely fits + cell.actualFontSize = (baseFontSize * 0.98) / maxOverflowRatio + + # Apply minimum font size if specified + if cell.style.minFontSize > 0 and cell.actualFontSize < cell.style.minFontSize: + cell.actualFontSize = cell.style.minFontSize + + # Re-wrap text with the new font size to ensure accurate line breaks + page.saveState() + if cell.style.fontFamily.len > 0: + var fontStyle: set[FontStyle] = {} + if cell.style.fontBold and cell.style.fontItalic: + fontStyle = {FS_BOLD, FS_ITALIC} + elif cell.style.fontBold: + fontStyle = {FS_BOLD} + elif cell.style.fontItalic: + fontStyle = {FS_ITALIC} + else: + fontStyle = {FS_REGULAR} + page.setFont(cell.style.fontFamily, fontStyle, cell.actualFontSize) + else: + page.setFont("Helvetica", {FS_REGULAR}, cell.actualFontSize) + + cell.wrappedLines = wrapText(cell.content, availableWidth, page) + page.restoreState() + + let lineHeight = page.getTextHeight("Ag") * 1.2 # 1.2x for comfortable line spacing + let requiredHeight = float64(cell.wrappedLines.len) * lineHeight + + cell.style.padding.top + cell.style.padding.bottom + + if requiredHeight > maxHeight: + maxHeight = requiredHeight + + # Use explicit minHeight if set, otherwise add 2mm margin to text bounds + let effectiveMinHeight = if row.minHeight > 0: row.minHeight else: maxHeight + 2.0 + row.height = max(maxHeight, effectiveMinHeight) + +proc drawCell(page: ContentBase, cell: Cell, x, y, width, height: float64) = + # Draw background + if cell.style.backgroundColor.r != 1.0 or cell.style.backgroundColor.g != 1.0 or + cell.style.backgroundColor.b != 1.0: + page.saveState() + page.setFillColor(cell.style.backgroundColor) + page.drawRect(x, y, width, height) + page.fill() + page.restoreState() + + # Draw borders + page.saveState() + page.setStrokeColor(cell.style.border.color) + page.setLineWidth(cell.style.border.width) + + if cell.style.border.left: + page.drawLine(x, y, x, y + height) + if cell.style.border.right: + page.drawLine(x + width, y, x + width, y + height) + if cell.style.border.top: + page.drawLine(x, y, x + width, y) + if cell.style.border.bottom: + page.drawLine(x, y + height, x + width, y + height) + + page.stroke() + page.restoreState() + + # Draw text (wrapped into multiple lines) + if cell.wrappedLines.len > 0 and cell.wrappedLines[0].len > 0: + page.saveState() + + # Set font if specified in cell style + if cell.style.fontFamily.len > 0: + # Determine font style + var fontStyle: set[FontStyle] = {} + if cell.style.fontBold and cell.style.fontItalic: + fontStyle = {FS_BOLD, FS_ITALIC} + elif cell.style.fontBold: + fontStyle = {FS_BOLD} + elif cell.style.fontItalic: + fontStyle = {FS_ITALIC} + else: + fontStyle = {FS_REGULAR} + + # Use actualFontSize (which may be auto-scaled) + let size = if cell.actualFontSize > 0: cell.actualFontSize + else: page.toUser(page.state.gState.fontSize) + + page.setFont(cell.style.fontFamily, fontStyle, size) + elif cell.style.fontSize > 0 or cell.actualFontSize > 0: + # Only font size changed, keep current font family + # Get current font to preserve family + let savedFont = page.state.gState.font + if savedFont != nil: + # Determine font style + var fontStyle: set[FontStyle] = {} + if cell.style.fontBold and cell.style.fontItalic: + fontStyle = {FS_BOLD, FS_ITALIC} + elif cell.style.fontBold: + fontStyle = {FS_BOLD} + elif cell.style.fontItalic: + fontStyle = {FS_ITALIC} + else: + fontStyle = {FS_REGULAR} + + # Use actualFontSize if available (auto-scaled), otherwise use specified fontSize + let size = if cell.actualFontSize > 0: cell.actualFontSize else: cell.style.fontSize + page.setFont("Helvetica", fontStyle, size) + + page.setFillColor(cell.style.textColor) + + let lineHeight = page.getTextHeight("Ag") * 1.2 # 1.2x for comfortable line spacing + let totalTextHeight = float64(cell.wrappedLines.len) * lineHeight + + # Calculate starting Y position for the first line's baseline based on vertical alignment + # drawText positions text by baseline. lineHeight includes space for ascenders. + var startY: float64 + case cell.style.verticalAlign + of ALIGN_TOP: + # Position text at the top - baseline is lineHeight from top (space for ascenders) + startY = y + cell.style.padding.top + lineHeight * 0.75 # Approximate ascent + of ALIGN_MIDDLE: + # Center the entire text block vertically + let textBlockHeight = totalTextHeight + let availableSpace = height - cell.style.padding.top - cell.style.padding.bottom + let topOffset = cell.style.padding.top + (availableSpace - textBlockHeight) / 2.0 + startY = y + topOffset + lineHeight * 0.75 + of ALIGN_BOTTOM: + # Position text at the bottom + let availableSpace = height - cell.style.padding.top - cell.style.padding.bottom + startY = y + cell.style.padding.top + availableSpace - totalTextHeight + lineHeight * 0.75 + else: + # Default to top alignment + startY = y + cell.style.padding.top + lineHeight * 0.75 + + # Draw each line + for lineIdx, line in cell.wrappedLines: + if line.len > 0: + let lineWidth = page.getTextWidth(line) + + # Calculate horizontal position for this line + var textX = x + cell.style.padding.left + case cell.style.horizontalAlign + of ALIGN_CENTER: + textX = x + (width - lineWidth) / 2.0 + of ALIGN_RIGHT: + textX = x + width - lineWidth - cell.style.padding.right + else: + discard + + let textY = startY + float64(lineIdx) * lineHeight + page.drawText(textX, textY, line) + + page.restoreState() + +proc drawRows*(table: Table, page: ContentBase, startRow, endRow: int, + x, y: float64, maxWidth: float64 = 0.0, + includeHeaders: bool = false): tuple[width, height: float64] = + ## Draws a subset of table rows from startRow to endRow (inclusive). + ## If includeHeaders is true, also draws header rows at the top. + ## Returns the dimensions (width, height) used. + ## This enables manual page wrapping by drawing different row ranges on different pages. + + # Get page dimensions + let pageSize = page.getPageSize() + let pageWidth = page.toUser(pageSize.width.toPT) + let rightMargin = 10.0 + let availableWidth = pageWidth - x - rightMargin + + var effectiveMaxWidth = availableWidth + if maxWidth > 0 and maxWidth < effectiveMaxWidth: + effectiveMaxWidth = maxWidth + + # Calculate dimensions (reuse existing calculations) + table.calculateColumnWidths(page, effectiveMaxWidth) + table.calculateRowHeights(page) + + var currentY = y + let startY = y + + # First, draw header rows if requested + if includeHeaders: + # Find header rows (those with headerStyle) + for row in table.rows: + var isHeader = false + if row.cells.len > 0: + # Check if this row uses header style + let firstCell = row.cells[0] + if firstCell.style.backgroundColor == table.headerStyle.backgroundColor: + isHeader = true + + if isHeader: + var currentX = x + for i, cell in row.cells: + if i < table.columnWidths.len: + var cellWidth = table.columnWidths[i] + if cell.colspan > 1: + for j in 1 ..< cell.colspan: + if i + j < table.columnWidths.len: + cellWidth += table.columnWidths[i + j] + page.drawCell(cell, currentX, currentY, cellWidth, row.height) + currentX += cellWidth + currentY += row.height + + # Draw the specified range of rows + let actualEndRow = min(endRow, table.rows.len - 1) + for rowIdx in startRow .. actualEndRow: + if rowIdx >= 0 and rowIdx < table.rows.len: + let row = table.rows[rowIdx] + var currentX = x + + for i, cell in row.cells: + if i < table.columnWidths.len: + var cellWidth = table.columnWidths[i] + if cell.colspan > 1: + for j in 1 ..< cell.colspan: + if i + j < table.columnWidths.len: + cellWidth += table.columnWidths[i + j] + page.drawCell(cell, currentX, currentY, cellWidth, row.height) + currentX += cellWidth + currentY += row.height + + result.width = table.totalWidth + result.height = currentY - startY + +# Convenience method to draw a simple table from 2D string array with auto-wrapping +proc drawSimpleTable*[T: ContentBase](page: var T, doc: auto, currentY: var float64, + data: seq[seq[string]], hasHeader: bool = true, + maxWidth: float64 = 190.0, spacing: float64 = 0.0): tuple[width, height: float64] = + ## Convenience method to quickly draw a table from a 2D string array. + ## Automatically handles page wrapping when needed. + ## Returns the total width and height used across all pages. + if data.len == 0: + return (0.0, 0.0) + + var table = newTable(10, currentY, autoFit = true) + + for rowIdx, rowData in data: + if rowIdx == 0 and hasHeader: + table.addHeaderRow(rowData) + else: + table.addDataRow(rowData) + + result = table.draw(page, doc, currentY, maxWidth, spacing) + +type + RowSplit* = object + startRow*: int + endRow*: int + height*: float64 + +proc draw*[T: ContentBase](table: Table, page: var T, doc: auto, + currentY: var float64, maxWidth: float64 = 190.0, + spacing: float64 = 0.0): tuple[width, height: float64] = + ## Draws table with automatic page wrapping as needed. + ## Updates `page` and `currentY` references for next element. + ## Automatically copies font settings from the original page to new pages. + ## Returns total dimensions (width, height) used across all pages. + + # Save current font settings to restore on new pages + let currentFont = page.state.gState.font + let currentFontSize = page.state.gState.fontSize + + # Calculate page splits + var splits = table.calculatePageSplits(page, currentY, maxWidth) + + # If no splits (table doesn't fit), start on new page + if splits.len == 0: + let pageSize = page.getPageSize() + page = doc.addPage(pageSize, PGO_PORTRAIT) + # Restore font settings on new page by copying font state and outputting PDF command + if currentFont != nil: + page.state.gState.font = currentFont + page.state.gState.fontSize = currentFontSize + page.put("BT /F", $currentFont.ID, " ", $currentFontSize, " Tf ET") + currentY = 10.0 + splits = table.calculatePageSplits(page, currentY, maxWidth) + + # Draw each split on its page + var totalHeight = 0.0 + for splitIdx, split in splits: + if splitIdx > 0: + let pageSize = page.getPageSize() + page = doc.addPage(pageSize, PGO_PORTRAIT) + # Restore font settings on new page by copying font state and outputting PDF command + if currentFont != nil: + page.state.gState.font = currentFont + page.state.gState.fontSize = currentFontSize + page.put("BT /F", $currentFont.ID, " ", $currentFontSize, " Tf ET") + currentY = 10.0 + + let dims = table.drawRows(page, split.startRow, split.endRow, + 10, currentY, maxWidth, includeHeaders = true) + totalHeight += dims.height + currentY += dims.height + + # Add spacing after table + currentY += spacing + + result.width = table.totalWidth + result.height = totalHeight + +proc calculatePageSplits*(table: Table, page: ContentBase, startY: float64, + maxWidth: float64 = 0.0): seq[RowSplit] = + ## Calculates how to split table rows across pages. + ## Returns a sequence of RowSplit objects indicating which rows fit on each page. + ## Use this information to manually draw the table across multiple pages. + + # Get page dimensions + let pageSize = page.getPageSize() + let pageWidth = page.toUser(pageSize.width.toPT) + let pageHeight = page.toUser(pageSize.height.toPT) + let bottomMargin = 10.0 + + # Calculate effective width + var effectiveMaxWidth = pageWidth - table.x - 10.0 + if maxWidth > 0 and maxWidth < effectiveMaxWidth: + effectiveMaxWidth = maxWidth + + # Calculate table dimensions + table.calculateColumnWidths(page, effectiveMaxWidth) + table.calculateRowHeights(page) + + result = @[] + if table.rows.len == 0: + return + + # Detect if first row is a header by checking if it uses header style + var hasHeader = false + var headerHeight = 0.0 + if table.rows.len > 0 and table.rows[0].cells.len > 0: + let firstCell = table.rows[0].cells[0] + if firstCell.style.backgroundColor == table.headerStyle.backgroundColor: + hasHeader = true + headerHeight = table.rows[0].height + + var currentRow = if hasHeader: 1 else: 0 # Start after header if present, otherwise from 0 + var isFirstPage = true + let topMargin = 10.0 # Standard top margin for new pages + + while currentRow < table.rows.len: + # Calculate available height for this page + let availableHeight = if isFirstPage: + pageHeight - startY - bottomMargin + else: + pageHeight - topMargin - bottomMargin + + # Determine how many rows fit (header is drawn separately by drawRows) + var heightUsed = headerHeight # Include header height in calculation + var lastRow = currentRow - 1 + + for rowIdx in currentRow ..< table.rows.len: + let totalHeightWithThisRow = heightUsed + table.rows[rowIdx].height + if totalHeightWithThisRow <= availableHeight: + heightUsed = totalHeightWithThisRow + lastRow = rowIdx + else: + break + + # Add this page's split + if lastRow >= currentRow: + result.add(RowSplit(startRow: currentRow, endRow: lastRow, height: heightUsed)) + currentRow = lastRow + 1 + isFirstPage = false + else: + # No rows fit on this page + if isFirstPage: + # On first page, if not even one data row fits with header, return empty + # This signals caller to move entire table to a new page + result = @[] + return + else: + # On subsequent pages, this means the row is too large + echo "Warning: Row ", currentRow, " (height: ", table.rows[currentRow].height, ") too large to fit on page (available: ", availableHeight, ")" + break