diff --git a/Makefile b/Makefile index b53fd02d..12e66bd9 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 # diff --git a/Source/API/PDFMultiDocumentGenerator.swift b/Source/API/PDFMultiDocumentGenerator.swift index 4dde2052..4f0bb63e 100644 --- a/Source/API/PDFMultiDocumentGenerator.swift +++ b/Source/API/PDFMultiDocumentGenerator.swift @@ -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) } diff --git a/Source/API/Table/PDFTable.swift b/Source/API/Table/PDFTable.swift index 82de2fcb..ccc4d436 100644 --- a/Source/API/Table/PDFTable.swift +++ b/Source/API/Table/PDFTable.swift @@ -82,6 +82,7 @@ public class PDFTable: PDFDocumentObject { table.padding = padding table.margin = margin table.showHeadersOnEveryPage = showHeadersOnEveryPage + table.shouldSplitCellsOnPageBreak = shouldSplitCellsOnPageBreak return table } diff --git a/Tests/TPPDFIntegrationTests/EmptyDocumentTests.swift b/Tests/TPPDFIntegrationTests/EmptyDocumentTests.swift index 9260b971..589c0073 100644 --- a/Tests/TPPDFIntegrationTests/EmptyDocumentTests.swift +++ b/Tests/TPPDFIntegrationTests/EmptyDocumentTests.swift @@ -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() diff --git a/Tests/TPPDFIntegrationTests/ExternalDocumentTests.swift b/Tests/TPPDFIntegrationTests/ExternalDocumentTests.swift index e09f080d..2b01015f 100644 --- a/Tests/TPPDFIntegrationTests/ExternalDocumentTests.swift +++ b/Tests/TPPDFIntegrationTests/ExternalDocumentTests.swift @@ -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") @@ -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.. 1) + #expect(generator.totalPages == output.pageCount) + var actualLines: [String] = [] + for index in 0.. PDFTable { let table = PDFTable(rows: 10, columns: 10) for row in 0..<10 { diff --git a/Tests/TPPDFTests/Layout/PDFLayoutIndentationsTests.swift b/Tests/TPPDFTests/Layout/PDFLayoutIndentationsTests.swift index 927d6fba..7d1b4e65 100644 --- a/Tests/TPPDFTests/Layout/PDFLayoutIndentationsTests.swift +++ b/Tests/TPPDFTests/Layout/PDFLayoutIndentationsTests.swift @@ -51,9 +51,21 @@ struct PDFLayoutIndentationsTests { func settingContainerIndentationReplacesEarlierValuesOnChosenSide(index: Int, right: Bool) { // Arrange var indentations = PDFLayoutIndentations() + indentations.header = (1, 2) + indentations.content = (3, 4) + indentations.footer = (5, 6) let containers = PDFContainer.all let values: [CGFloat] = [10, 20, 30] let regionStart = index / 3 * 3 + var expectedLeft: [CGFloat] = [1, 1, 1, 3, 3, 3, 5, 5, 5] + var expectedRight: [CGFloat] = [2, 2, 2, 4, 4, 4, 6, 6, 6] + for position in regionStart..<(regionStart + 3) { + if right { + expectedRight[position] = values[index % 3] + } else { + expectedLeft[position] = values[index % 3] + } + } // Act for position in regionStart...index { @@ -63,10 +75,11 @@ struct PDFLayoutIndentationsTests { indentations.setLeft(indentation: values[position % 3], in: containers[position]) } } - let result = right ? indentations.rightIn(container: containers[index]) - : indentations.leftIn(container: containers[index]) // Assert - #expect(result == values[index % 3]) + for (position, container) in containers.enumerated() { + #expect(indentations.leftIn(container: container) == expectedLeft[position]) + #expect(indentations.rightIn(container: container) == expectedRight[position]) + } } } diff --git a/Tests/TPPDFTests/Layout/PDFLayoutTests.swift b/Tests/TPPDFTests/Layout/PDFLayoutTests.swift index f819785e..d075ccb9 100644 --- a/Tests/TPPDFTests/Layout/PDFLayoutTests.swift +++ b/Tests/TPPDFTests/Layout/PDFLayoutTests.swift @@ -50,28 +50,44 @@ struct PDFLayoutTests { let container = PDFContainer.all[index] let values: [CGFloat] = [111, 222, 333, 444, 555, 666, 777, 888, 999] let value = values[index] + layout.heights.header = [.headerLeft: 1, .headerCenter: 2, .headerRight: 3] + layout.heights.content = 4 + layout.heights.footer = [.footerLeft: 5, .footerCenter: 6, .footerRight: 7] + var expected: [CGFloat] = [1, 2, 3, 4, 4, 4, 5, 6, 7] + if (3...5).contains(index) { + expected.replaceSubrange(3...5, with: [value, value, value]) + } else { + expected[index] = value + } // Act layout.setContentOffset(in: container, to: value) - let offset = layout.getContentOffset(in: container) // Assert - #expect(offset == value) + for (position, otherContainer) in PDFContainer.all.enumerated() { + #expect(layout.getContentOffset(in: otherContainer) == expected[position]) + } } - @Test func resettingLayoutClearsPreviousHeightsAndIndentations() { + @Test func resettingLayoutClearsEveryRegionAndMargins() { // Arrange let layout = PDFLayout() - layout.setContentOffset(in: .contentCenter, to: 123) - layout.indentation.setLeft(indentation: 123, in: .contentCenter) - let heights = layout.heights - let indentations = layout.indentation + layout.margin = EdgeInsets(top: 1, left: 2, bottom: 3, right: 4) + for container in PDFContainer.all { + layout.setContentOffset(in: container, to: 123) + layout.indentation.setLeft(indentation: 12, in: container) + layout.indentation.setRight(indentation: 34, in: container) + } // Act layout.reset() // Assert - #expect(layout.heights != heights) - #expect(layout.indentation != indentations) + for container in PDFContainer.all { + #expect(layout.getContentOffset(in: container) == 0) + #expect(layout.indentation.leftIn(container: container) == 0) + #expect(layout.indentation.rightIn(container: container) == 0) + } + #expect(layout.margin == .zero) } } diff --git a/Tests/TPPDFTests/List/PDFListObjectTests.swift b/Tests/TPPDFTests/List/PDFListObjectTests.swift index 63b9308e..57395f3d 100644 --- a/Tests/TPPDFTests/List/PDFListObjectTests.swift +++ b/Tests/TPPDFTests/List/PDFListObjectTests.swift @@ -27,23 +27,24 @@ struct PDFListObjectTests { let object = PDFListObject(list: makeList()) let document = PDFDocument(format: .a4) let generator = PDFGenerator(document: document) - let xOffsets: [CGFloat] = [10, 20, 10, 20, 10, 20, 20, 30, 30, 20, 30, 10, 20] - let expectedTexts = [ - "1.", "Heading 1", "?.", "Heading 2", "\u{00B7}", "Heading 3", "-", - "Subheading 1", "Subheading 2", "\u{00B7}", "Subheading 3", "+", "Heading 4", + let expectations: [(text: String, indent: CGFloat, line: Int, width: CGFloat, watchWidth: CGFloat, catalystWidth: CGFloat)] = [ + ("1.", 10, 0, 12, 12, 12), + ("Heading 1", 20, 0, 75, 76.2, 78), + ("?.", 10, 1, 13, 13, 13), + ("Heading 2", 20, 1, 78, 78, 80), + ("\u{00B7}", 10, 2, 4, 5.4, 4), + ("Heading 3", 20, 2, 79, 79, 81), + ("-", 20, 3, 8, 6.9, 8), + ("Subheading 1", 30, 3, 103, 104.5, 106.4), + ("Subheading 2", 30, 4, 106, 106, 108.8), + ("\u{00B7}", 20, 5, 4, 5.4, 4), + ("Subheading 3", 30, 5, 105.4, 106.2, 109.2), + ("+", 10, 6, 11, 9.47, 11), + ("Heading 4", 20, 6, 79, 79, 81.2), ] - #if os(watchOS) - let expectedWidths: [CGFloat] = [12, 76.2, 13, 78, 5.4, 79, 6.9, 104.5, 106, 5.4, 106.2, 9.47, 79] - #elseif targetEnvironment(macCatalyst) - let expectedWidths: [CGFloat] = [12, 78, 13, 80, 4, 81, 8, 106.4, 108.8, 4, 109.2, 11, 81.2] - #else - let expectedWidths: [CGFloat] = [12, 75, 13, 78, 4, 79, 8, 103, 106, 4, 105.4, 11, 79] - #endif #if (os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)) && !targetEnvironment(macCatalyst) - let expectedYPositions: [CGFloat] = [60, 60, 81, 81, 102, 102, 123, 123, 144, 165, 165, 186, 186] let expectedHeight: CGFloat = 21 #else - let expectedYPositions: [CGFloat] = [60, 60, 80, 80, 100, 100, 120, 120, 140, 160, 160, 180, 180] let expectedHeight: CGFloat = 20 #endif @@ -51,15 +52,22 @@ struct PDFListObjectTests { let result = try object.calculate(generator: generator, container: .contentLeft) // Assert - try #require(result.count == 13) - for index in result.indices { + try #require(result.count == expectations.count) + for (index, expected) in expectations.enumerated() { + #if os(watchOS) + let width = expected.watchWidth + #elseif targetEnvironment(macCatalyst) + let width = expected.catalystWidth + #else + let width = expected.width + #endif #expect(result[index].0 == .contentLeft) let item = try #require(result[index].1 as? PDFAttributedTextObject) - #expect(item.frame.origin.x == document.layout.margin.left + xOffsets[index]) - #expect(item.frame.origin.y == expectedYPositions[index]) - #expect(abs(item.frame.width - expectedWidths[index]) < 1) + #expect(item.frame.origin.x == document.layout.margin.left + expected.indent) + #expect(item.frame.origin.y == 60 + CGFloat(expected.line) * expectedHeight) + #expect(abs(item.frame.width - width) < 1, "Width for \(expected.text)") #expect(item.frame.height == expectedHeight) - #expect(item.simpleText == PDFSimpleText(text: expectedTexts[index])) + #expect(item.simpleText == PDFSimpleText(text: expected.text)) } } diff --git a/Tests/TPPDFTests/PDFDocumentObjectsTests.swift b/Tests/TPPDFTests/PDFDocumentObjectsTests.swift index 0cf980b4..aa4c0911 100644 --- a/Tests/TPPDFTests/PDFDocumentObjectsTests.swift +++ b/Tests/TPPDFTests/PDFDocumentObjectsTests.swift @@ -57,18 +57,24 @@ struct PDFDocumentObjectsTests { #expect(object.image == image) } - @Test func addingImageRowPreservesImagesSpacingAndExplicitContainer() throws { + @Test(arguments: [false, true]) + func addingImageRowPreservesImagesSpacingAndContainer(explicit: Bool) throws { // Arrange let document = PDFDocument(format: .a4) let image = try loadImage() let images = [image, image, image] let spacing: CGFloat = 10 + let container: PDFContainer = explicit ? .headerRight : .contentLeft // Act - document.add(.headerRight, imagesInRow: images, spacing: spacing) + if explicit { + document.add(.headerRight, imagesInRow: images, spacing: spacing) + } else { + document.add(imagesInRow: images, spacing: spacing) + } // Assert - let object = try onlyObject(PDFImageRowObject.self, in: document, container: .headerRight) + let object = try onlyObject(PDFImageRowObject.self, in: document, container: container) #expect(object.images == images) #expect(object.spacing == spacing) } @@ -175,20 +181,20 @@ struct PDFDocumentObjectsTests { #expect(object.list == list) } - @Test(arguments: [false, true]) - func settingIndentationPreservesValueSideAndContainer(explicit: Bool) throws { + @Test(arguments: [false, true], [false, true]) + func settingIndentationPreservesValueSideAndContainer(explicit: Bool, left: Bool) throws { // Arrange let document = PDFDocument(format: .a4) let value: CGFloat = 20 let container: PDFContainer = explicit ? .headerRight : .contentLeft // Act - if explicit { document.set(.headerRight, indent: value, left: true) } else { document.set(indent: value, left: true) } + if explicit { document.set(.headerRight, indent: value, left: left) } else { document.set(indent: value, left: left) } // Assert let object = try onlyObject(PDFIndentationObject.self, in: document, container: container) #expect(object.indentation == value) - #expect(object.left) + #expect(object.left == left) } @Test(arguments: [false, true]) diff --git a/Tests/TPPDFTests/PDFGeneratorGenerationTests.swift b/Tests/TPPDFTests/PDFGeneratorGenerationTests.swift index a0e35c88..b3e8cc39 100644 --- a/Tests/TPPDFTests/PDFGeneratorGenerationTests.swift +++ b/Tests/TPPDFTests/PDFGeneratorGenerationTests.swift @@ -12,7 +12,7 @@ import Testing @testable import TPPDF struct PDFGeneratorGenerationTests { - @Test(arguments: [(false, false), (true, false), (false, true)]) + @Test(arguments: [false, true], [false, true]) func generatingURLWritesReadablePDFWithOrWithoutFilenameSuffix(suffix: Bool, debug: Bool) throws { // Arrange let generator = PDFGenerator(document: PDFDocument(format: .a4)) @@ -58,26 +58,70 @@ struct PDFGeneratorGenerationTests { #expect(object.called) } - @Test(arguments: [("header", 9), ("content", 6), ("footer", 3)]) - func extractingRegionReturnsItsObjects(region: String, expectedCount: Int) { + @Test(arguments: Region.allCases, [false, true]) + func extractingRegionPreservesObjectIdentityAndOrder(region: Region, empty: Bool) throws { // Arrange let containers: [PDFContainer] = [ - .headerLeft, .headerLeft, .headerLeft, .headerCenter, .headerCenter, .headerCenter, - .headerRight, .headerRight, .headerRight, .contentLeft, .contentLeft, - .contentCenter, .contentCenter, .contentRight, .contentRight, .footerLeft, .footerCenter, .footerRight, + .footerRight, .contentCenter, .headerLeft, .none, .headerRight, + .contentLeft, .footerLeft, .headerCenter, .contentRight, .footerCenter, .headerLeft, ] - let objects: [PDFLocatedRenderObject] = containers.map { ($0, PDFSpaceObject(space: 10)) } + let objects: [PDFLocatedRenderObject] = empty ? [] : containers.map { ($0, PDFSpaceObject(space: 10)) } + let expectedIndices = empty ? [] : region.expectedIndices // Act - let extracted: [PDFLocatedRenderObject] - switch region { - case "header": extracted = PDFGenerator.extractHeaderObjects(objects: objects) - case "footer": extracted = PDFGenerator.extractFooterObjects(objects: objects) - default: extracted = PDFGenerator.extractContentObjects(objects: objects) + let extracted = region.extract(objects) + + // Assert + try #require(extracted.count == expectedIndices.count) + for (index, sourceIndex) in expectedIndices.enumerated() { + #expect(extracted[index].0 == objects[sourceIndex].0) + #expect(extracted[index].1 === objects[sourceIndex].1) + } + } + + @Test func renderingPropagatesDrawErrors() throws { + // Arrange + let object = FailingDrawingObject() + let generator = PDFGenerator(document: PDFDocument(format: .a4)) + let context = try #require(PDFContextGraphics.createBitmapContext(size: CGSize(width: 100, height: 100))) + + // Act + let error = #expect(throws: DrawFailure.self) { + try generator.render(object: object, in: .contentLeft, in: PDFContext(cgContext: context)) } // Assert - #expect(extracted.count == expectedCount) + #expect(error == .expected) + } + + enum Region: CaseIterable { + case header, content, footer + + var expectedIndices: [Int] { + switch self { + case .header: [2, 4, 7, 10] + case .content: [1, 3, 5, 8] + case .footer: [0, 6, 9] + } + } + + func extract(_ objects: [PDFLocatedRenderObject]) -> [PDFLocatedRenderObject] { + switch self { + case .header: PDFGenerator.extractHeaderObjects(objects: objects) + case .content: PDFGenerator.extractContentObjects(objects: objects) + case .footer: PDFGenerator.extractFooterObjects(objects: objects) + } + } + } + + private enum DrawFailure: Error { + case expected + } + + private final class FailingDrawingObject: PDFRenderObject { + override func draw(generator _: PDFGenerator, container _: PDFContainer, in _: PDFContext) throws { + throw DrawFailure.expected + } } @Test func tableOfContentsUsesHeadingStylesAndPreservesNestedLevels() { diff --git a/Tests/TPPDFTests/Table/PDFTableTests.swift b/Tests/TPPDFTests/Table/PDFTableTests.swift index f02931c2..c910fce6 100644 --- a/Tests/TPPDFTests/Table/PDFTableTests.swift +++ b/Tests/TPPDFTests/Table/PDFTableTests.swift @@ -26,6 +26,22 @@ struct PDFTableTests { #expect(!table.showHeadersOnEveryPage) } + @Test(arguments: [false, true], [false, true]) + func copyingTablePreservesPaginationSettings(splitCells: Bool, repeatHeaders: Bool) { + // Arrange + let table = PDFTable(rows: 2, columns: 2) + table.shouldSplitCellsOnPageBreak = splitCells + table.showHeadersOnEveryPage = repeatHeaders + + // Act + let copy = table.copy + + // Assert + #expect(copy !== table) + #expect(copy.shouldSplitCellsOnPageBreak == splitCells) + #expect(copy.showHeadersOnEveryPage == repeatHeaders) + } + @Test func settingStyleThroughCellSubscriptUpdatesStoredCell() { // Arrange let table = PDFTable(rows: 0, columns: 0) diff --git a/Tests/TPPDFTests/Table/PDFTableValidatorTests.swift b/Tests/TPPDFTests/Table/PDFTableValidatorTests.swift index 2face859..5bd77caa 100644 --- a/Tests/TPPDFTests/Table/PDFTableValidatorTests.swift +++ b/Tests/TPPDFTests/Table/PDFTableValidatorTests.swift @@ -12,7 +12,7 @@ import Testing struct PDFTableValidatorTests { @Test(arguments: [false, true]) - func validatingTableWithEmptyOrConfiguredCellsSucceeds(configured: Bool) { + func validatingTableWithUnconfiguredOrConfiguredCellsSucceeds(configured: Bool) { // Arrange let table = PDFTable(rows: 2, columns: 4) if configured { @@ -27,36 +27,106 @@ struct PDFTableValidatorTests { } } - @Test(arguments: ["empty", "alignmentCount", "alignmentRowSize", "columnWidths"]) - func invalidTableDataThrowsExpectedValidationError(scenario: String) throws { + @Test func emptyDataThrowsTableIsEmpty() { // Arrange - let data = [["1|1", "1|2", "1|3", "1|4"], ["2|1", "2|2", "2|3", "2|4"]] - let input = scenario == "empty" ? [] : data - let alignments: [[PDFTableCellAlignment]]? = scenario == "alignmentCount" ? [] - : scenario == "alignmentRowSize" ? [[], []] : nil - let widths: [CGFloat]? = scenario == "columnWidths" ? [] : nil - let expectedMessage = scenario == "alignmentCount" ? "Data and alignment must be equal size!" - : scenario == "columnWidths" ? "Data and widths for row with index 0 does not have the same amount!" - : "Data and alignment for row with index 0 does not have the same amount!" + let data: [[Any?]] = [] // Act - let caughtError = #expect(throws: PDFError.self) { - try PDFTableValidator.validateTableData(data: input, alignments: alignments, columnWidths: widths) + let error = #expect(throws: PDFError.self) { + try PDFTableValidator.validateTableData(data: data) } // Assert - let error = try #require(caughtError) - switch error { - case .tableIsEmpty: - #expect(scenario == "empty") - case let .tableStructureInvalid(message): - #expect(scenario != "empty") - #expect(message == expectedMessage) - default: - Issue.record("Unexpected validation error: \(error)") + guard case .tableIsEmpty? = error else { + Issue.record("Expected tableIsEmpty, got \(String(describing: error))") + return } } + @Test(arguments: InvalidStructure.allCases) + func invalidStructureReportsTheMismatchedRow(scenario: InvalidStructure) { + // Arrange + let input = scenario.input + + // Act + let error = #expect(throws: PDFError.self) { + try PDFTableValidator.validateTableData(data: input.data, alignments: input.alignments, columnWidths: input.widths) + } + + // Assert + guard case let .tableStructureInvalid(message)? = error else { + Issue.record("Expected tableStructureInvalid, got \(String(describing: error))") + return + } + #expect(message == input.message) + } + + enum InvalidStructure: CaseIterable { + case alignmentCount, firstRowAlignment, lastRowAlignment, firstRowWidths, lastRowWidths + + var input: (data: [[Any?]], alignments: [[PDFTableCellAlignment]]?, widths: [CGFloat]?, message: String) { + switch self { + case .alignmentCount: + ([[1, 2], [3, 4]], [], nil, "Data and alignment must be equal size!") + case .firstRowAlignment: + ([[1, 2], [3, 4]], [[], []], nil, + "Data and alignment for row with index 0 does not have the same amount!") + case .lastRowAlignment: + ([[1, 2], [3, 4]], [[.left, .right], [.left]], nil, + "Data and alignment for row with index 1 does not have the same amount!") + case .firstRowWidths: + ([[1, 2], [3, 4]], nil, [], "Data and widths for row with index 0 does not have the same amount!") + case .lastRowWidths: + ([[1, 2], [3]], nil, [0.5, 0.5], "Data and widths for row with index 1 does not have the same amount!") + } + } + } + + @Test(arguments: [false, true]) + func validDataAcceptsOptionalOrMatchingAlignmentAndWidths(configured: Bool) { + // Arrange + let data: [[Any?]] = [[nil, "text"], [1, 2.5]] + let alignments: [[PDFTableCellAlignment]]? = configured ? [[.left, .right], [.top, .bottom]] : nil + let widths: [CGFloat]? = configured ? [0.25, 0.75] : nil + + // Act and Assert + #expect(throws: Never.self) { + try PDFTableValidator.validateTableData(data: data, alignments: alignments, columnWidths: widths) + } + } + + @Test func emptyTableThrowsTableIsEmpty() { + // Arrange + let table = PDFTable(rows: 0, columns: 0) + + // Act + let error = #expect(throws: PDFError.self) { + try PDFTableValidator.validateTable(table: table) + } + + // Assert + guard case .tableIsEmpty? = error else { + Issue.record("Expected tableIsEmpty, got \(String(describing: error))") + return + } + } + + @Test(arguments: [false, true]) + func mergedHeadersDisableRepetitionOnlyWhenColumnHeightsDiffer(uneven: Bool) throws { + // Arrange + let table = PDFTable(rows: 4, columns: 2) + table.style.columnHeaderCount = 1 + table.showHeadersOnEveryPage = true + table[rows: 0..<2, column: 0].merge() + if !uneven { table[rows: 0..<2, column: 1].merge() } + + // Act + try PDFTableValidator.validateTable(table: table) + + // Assert + #expect(table.showHeadersOnEveryPage == !uneven) + } + #if os(macOS) && swift(>=6.2) @Test func assigningContentWithWrongColumnCountTerminatesProcess() async { // Arrange diff --git a/Tests/TPPDFTests/Utils/PDFCalculationsTests.swift b/Tests/TPPDFTests/Utils/PDFCalculationsTests.swift index 2d0a48c4..9f2abc88 100644 --- a/Tests/TPPDFTests/Utils/PDFCalculationsTests.swift +++ b/Tests/TPPDFTests/Utils/PDFCalculationsTests.swift @@ -34,4 +34,47 @@ struct PDFCalculationsTests { #expect(result.size.height == CGFloat(expectedHeight)) #expect(result.remainder == remainder) } + + @Test(arguments: [ + (bounds: CGSize(width: 1000, height: 1000), rendersText: true, hasRemainder: false), + (bounds: CGSize(width: 100, height: 20), rendersText: true, hasRemainder: true), + (bounds: CGSize(width: 100, height: 1), rendersText: false, hasRemainder: true), + ]) + func splittingPreservesDistinctUnicodeTextAndAttributes( + scenario: (bounds: CGSize, rendersText: Bool, hasRemainder: Bool) + ) { + // Arrange + let text = NSMutableAttributedString(string: "FIRST café e\u{301} 👩🏽‍💻\nSECOND Ω\nTHIRD 終", + attributes: [.font: Font.systemFont(ofSize: 12)]) + text.addAttribute(.foregroundColor, value: Color.red, range: NSRange(location: 0, length: 5)) + let original = NSAttributedString(attributedString: text) + + // Act + let result = PDFCalculations.calculateTextSizeAndRemainder(of: text, in: scenario.bounds) + let reconstructed = NSMutableAttributedString(attributedString: result.text) + if let remainder = result.remainder { reconstructed.append(remainder) } + + // Assert + #expect((result.text.length > 0) == scenario.rendersText) + #expect((result.remainder != nil) == scenario.hasRemainder) + #expect(reconstructed == original) + #expect(text == original) + } + + @Test(arguments: [CGFloat(0), -1]) + func noRemainingHeightDefersAllTextWithoutRendering(height: CGFloat) { + // Arrange + let generator = PDFGenerator(document: PDFDocument(format: .a4)) + let availableHeight = PDFCalculations.calculateAvailableFrameHeight(for: generator, in: .contentLeft) + generator.layout.heights.content = availableHeight - height + let text = NSAttributedString(string: "Deferred text") + + // Act + let result = PDFCalculations.calculateText(generator: generator, container: .contentLeft, text: text) + + // Assert + #expect(result.frame == .zero) + #expect(result.render == nil) + #expect(result.remainder == text) + } }