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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
# Xcode scheme used to build TPPDF
XCODE_SCHEME = TPPDF

# Optional macOS test identifier, for example TPPDFTests/PDFTableValidatorTests
TEST_FILTER ?=

# iOS Simulator OS version (defaults to '26.2', can be overridden via IOS_SIMULATOR_OS=26.2)
IOS_SIMULATOR_OS ?= 26.2

Expand Down Expand Up @@ -219,7 +222,7 @@ test-macos: build-macos test-enable
set -o pipefail && NSUnbufferedIO=YES xcrun xcodebuild \
-scheme $(XCODE_SCHEME) \
-destination 'platform=macOS' \
test | tee raw-test-macos.log | xcbeautify --preserve-unbeautified
test $(if $(TEST_FILTER),-only-testing:$(TEST_FILTER),) | tee raw-test-macos.log | xcbeautify --preserve-unbeautified

## Run unit tests for TPPDF on Mac Catalyst
#
Expand Down
2 changes: 1 addition & 1 deletion Source/API/PDFMultiDocumentGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public class PDFMultiDocumentGenerator: PDFGeneratorProtocol {
public func generate(to target: URL, info: PDFInfo?) throws {
assert(!generators.isEmpty, "At least one document is required!")

let context = PDFContextGraphics.createPDFContext(url: target, bounds: bounds, info: self.info)
let context = PDFContextGraphics.createPDFContext(url: target, bounds: bounds, info: info ?? self.info)
try processDocuments(context: context)
PDFContextGraphics.closePDFContext(context)
}
Expand Down
1 change: 1 addition & 0 deletions Source/API/Table/PDFTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public class PDFTable: PDFDocumentObject {
table.padding = padding
table.margin = margin
table.showHeadersOnEveryPage = showHeadersOnEveryPage
table.shouldSplitCellsOnPageBreak = shouldSplitCellsOnPageBreak
return table
}

Expand Down
5 changes: 3 additions & 2 deletions Tests/TPPDFIntegrationTests/EmptyDocumentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import Testing
import PDFKit

struct EmptyDocumentTests {
@Test func generatingEmptyDocumentWithDebugOverlayProducesOnePage() throws {
@Test(arguments: [false, true])
func generatingEmptyDocumentProducesOnePage(debug: Bool) throws {
// Arrange
let document = TPPDF.PDFDocument(format: .usLetter)
let generator = PDFGenerator(document: document)
generator.debug = true
generator.debug = debug

// Act
let data = try generator.generateData()
Expand Down
91 changes: 89 additions & 2 deletions Tests/TPPDFIntegrationTests/ExternalDocumentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ struct ExternalDocumentTests {
(before: false, after: false, documents: 2, pages: 100),
(before: false, after: false, documents: 5, pages: 250),
], [false, true])
func externalDocumentsPreservePageCountsWithSurroundingContentAndDebugMode(
func externalDocumentsPreservePagesAndContentOrderWithDebugMode(
scenario: (before: Bool, after: Bool, documents: Int, pages: Int), debug: Bool
) throws {
// Arrange
let url = try #require(Bundle.module.url(forResource: "50-pages", withExtension: "pdf"))
let sourceDocument = PDFKit.PDFDocument(url: url)
let source = try #require(sourceDocument)
let document = TPPDF.PDFDocument(format: .a4)
if scenario.before {
document.add(text: "Text before external document")
Expand All @@ -47,7 +49,92 @@ struct ExternalDocumentTests {

// Assert
#expect(generator.totalPages == scenario.pages)
#expect(output.pageCount == scenario.pages)
try #require(output.pageCount == scenario.pages)
let externalStart = scenario.before ? 1 : 0
if scenario.before {
#expect(output.page(at: 0)?.string?.contains("Text before external document") == true)
}
if scenario.after {
#expect(output.page(at: scenario.pages - 1)?.string?.contains("Text after external document") == true)
}
for documentIndex in 0..<scenario.documents {
for pageIndex in 0..<50 {
let sourcePage = try #require(source.page(at: pageIndex))
let outputPage = try #require(output.page(at: externalStart + documentIndex * 50 + pageIndex))
let sourceText = try #require(sourcePage.string)
#expect(!sourceText.isEmpty)
#expect(outputPage.string == sourceText, "External document \(documentIndex), page \(pageIndex)")
}
}
}

@Test(arguments: [[1], [50], [50, 1, 25], [2, 2]], [false, true])
func selectedExternalPagesPreserveRequestedOrderAndDuplicates(pages: [Int], debug: Bool) throws {
// Arrange
let url = try #require(Bundle.module.url(forResource: "50-pages", withExtension: "pdf"))
let sourceDocument = PDFKit.PDFDocument(url: url)
let source = try #require(sourceDocument)
let document = TPPDF.PDFDocument(format: .a4)
document.add(externalDocument: .init(url: url, pages: pages))
let generator = PDFGenerator(document: document)
generator.debug = debug

// Act
let data = try generator.generateData()
let outputDocument = PDFKit.PDFDocument(data: data)
let output = try #require(outputDocument)

// Assert
try #require(output.pageCount == pages.count)
#expect(generator.totalPages == pages.count)
for (index, sourceIndex) in pages.enumerated() {
let sourcePage = try #require(source.page(at: sourceIndex - 1))
let outputPage = try #require(output.page(at: index))
let sourceText = try #require(sourcePage.string)
#expect(!sourceText.isEmpty)
#expect(outputPage.string == sourceText)
}
}

@Test(arguments: [-1, 0, 51])
func outOfBoundsExternalPageThrowsItsRequestedIndex(index: Int) throws {
// Arrange
let url = try #require(Bundle.module.url(forResource: "50-pages", withExtension: "pdf"))
let document = TPPDF.PDFDocument(format: .a4)
document.add(externalDocument: .init(url: url, pages: [index]))
let generator = PDFGenerator(document: document)

// Act
let error = #expect(throws: PDFError.self) {
try generator.generateData()
}

// Assert
guard case let .pageOutOfBounds(actualIndex)? = error else {
Issue.record("Expected pageOutOfBounds, got \(String(describing: error))")
return
}
#expect(actualIndex == index)
}

@Test func missingExternalDocumentThrowsItsURL() {
// Arrange
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".pdf")
let document = TPPDF.PDFDocument(format: .a4)
document.add(externalDocument: .init(url: url))
let generator = PDFGenerator(document: document)

// Act
let error = #expect(throws: PDFError.self) {
try generator.generateData()
}

// Assert
guard case let .externalDocumentURLInvalid(actualURL)? = error else {
Issue.record("Expected externalDocumentURLInvalid, got \(String(describing: error))")
return
}
#expect(actualURL == url)
}
}
#endif
52 changes: 52 additions & 0 deletions Tests/TPPDFIntegrationTests/MultiDocumentGenerationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// MultiDocumentGenerationTests.swift
// TPPDF
//
// Created by Philip Niedertscheider on 11.10.2026.
// Copyright © 2016-2026 techprimate GmbH. All rights reserved.
//

import Foundation
import Testing
@testable import TPPDF

#if canImport(PDFKit)
import PDFKit

struct MultiDocumentGenerationTests {
@Test(arguments: [false, true], [false, true])
func generationPreservesDocumentOrderAndUsesExplicitOrFirstDocumentMetadata(writeFile: Bool, overrideInfo: Bool) throws {
// Arrange
let first = TPPDF.PDFDocument(format: .a4)
first.info = PDFInfo(title: "First title", author: "First author", subject: "First subject")
first.add(text: "FIRST DOCUMENT")
let second = TPPDF.PDFDocument(format: .a4)
second.info = PDFInfo(title: "Second title", author: "Second author", subject: "Second subject")
second.add(text: "SECOND DOCUMENT")
let generator = PDFMultiDocumentGenerator(documents: [first, second])
let info = overrideInfo ? PDFInfo(title: "Override title", author: "Override author", subject: "Override subject") : nil
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".pdf")
defer { if writeFile { try? FileManager.default.removeItem(at: url) } }

// Act
let data: Data
if writeFile {
try generator.generate(to: url, info: info)
data = try Data(contentsOf: url)
} else {
data = try generator.generateData(info: info)
}
let outputDocument = PDFKit.PDFDocument(data: data)
let output = try #require(outputDocument)

// Assert
try #require(output.pageCount == 2)
#expect(output.page(at: 0)?.string?.contains("FIRST DOCUMENT") == true)
#expect(output.page(at: 1)?.string?.contains("SECOND DOCUMENT") == true)
let metadata = try #require(output.documentAttributes)
#expect(metadata[PDFDocumentAttribute.titleAttribute] as? String == (overrideInfo ? "Override title" : "First title"))
#expect(metadata[PDFDocumentAttribute.authorAttribute] as? String == (overrideInfo ? "Override author" : "First author"))
#expect(metadata[PDFDocumentAttribute.subjectAttribute] as? String == (overrideInfo ? "Override subject" : "First subject"))
}
}
#endif
32 changes: 32 additions & 0 deletions Tests/TPPDFIntegrationTests/TableHeaderPaginationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,38 @@ struct TableHeaderPaginationTests {
}
}

@Test(arguments: [false, true])
func copiedSplittableTableStillRendersInRemainingPageSpace(copied: Bool) throws {
// Arrange
let table = PDFTable(rows: 3, columns: 1)
let style = PDFTableCellStyle(font: Font.systemFont(ofSize: 12))
table.style = PDFTableStyle(rowHeaderCount: 0, columnHeaderCount: 1, footerCount: 0,
columnHeaderStyle: style, contentStyle: style)
table.padding = 4
table.margin = 3
table.showHeadersOnEveryPage = true
table.shouldSplitCellsOnPageBreak = true
table.content = [["HEADER"], ["FIRSTBODY"], ["LASTBODY"]]
let document = TPPDF.PDFDocument(format: .a4)
let contentHeight = document.layout.height - document.layout.margin.top - document.layout.margin.bottom
document.add(space: contentHeight - 40)
let object = PDFTableObject(table: table)
document.objects.append((.contentLeft, copied ? object.copy : object))
let generator = PDFGenerator(document: document)

// Act
let data = try generator.generateData()
let outputDocument = PDFKit.PDFDocument(data: data)
let output = try #require(outputDocument)

// Assert
#expect(output.pageCount == 2)
#expect(generator.totalPages == output.pageCount)
#expect(output.page(at: 0)?.string?.contains("HEADER") == true)
#expect(output.string?.contains("FIRSTBODY") == true)
#expect(output.string?.contains("LASTBODY") == true)
}

@Test(arguments: [0, 1], [0, 1, 2])
func generatedPagesPreserveHeadersAndAllBodyText(headerRows: Int, styledColumns: Int) throws {
let table = PDFTable(rows: 60, columns: 2)
Expand Down
59 changes: 59 additions & 0 deletions Tests/TPPDFIntegrationTests/TextPaginationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// TextPaginationTests.swift
// TPPDF
//
// Created by Philip Niedertscheider on 11.10.2026.
// Copyright © 2016-2026 techprimate GmbH. All rights reserved.
//

import Foundation
import Testing
@testable import TPPDF

#if canImport(PDFKit)
import PDFKit

struct TextPaginationTests {
@Test(arguments: [false, true], [false, true])
func multipageTextPreservesEveryLineInOrderWithHeadersAndFooters(attributed: Bool, debug: Bool) throws {
// Arrange
let layout = PDFPageLayout(size: CGSize(width: 280, height: 200),
margin: EdgeInsets(top: 20, left: 20, bottom: 20, right: 20),
space: (header: 5, footer: 5))
let document = TPPDF.PDFDocument(layout: layout)
document.add(.headerLeft, text: "HEADER")
document.add(.footerRight, text: "FOOTER")
let expectedLines = (0..<100).map { String(format: "LINE%03d", $0) }
let text = expectedLines.joined(separator: "\n")
if attributed {
document.add(attributedText: NSAttributedString(string: text, attributes: [.font: Font.systemFont(ofSize: 12)]))
} else {
document.set(font: Font.systemFont(ofSize: 12))
document.add(text: text)
}
let generator = PDFGenerator(document: document)
generator.debug = debug

// Act
let data = try generator.generateData()
let outputDocument = PDFKit.PDFDocument(data: data)
let output = try #require(outputDocument)

// Assert
#expect(output.pageCount > 1)
#expect(generator.totalPages == output.pageCount)
var actualLines: [String] = []
for index in 0..<output.pageCount {
let page = try #require(output.page(at: index))
let pageText = try #require(page.string)
let lines = pageText.components(separatedBy: .newlines)
#expect(lines.filter { $0 == "HEADER" }.count == 1)
#expect(lines.filter { $0 == "FOOTER" }.count == 1)
let bodyLines = lines.filter { $0.hasPrefix("LINE") }
#expect(!bodyLines.isEmpty, "Page \(index) must not be blank")
actualLines += bodyLines
}
#expect(actualLines == expectedLines)
}
}
#endif
Loading
Loading