From 68903f00bac70397f12b8da4cb76daca7c00df67 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Sun, 11 Oct 2026 15:32:14 +0200 Subject: [PATCH 1/3] test: Harden behavior coverage and fix discovered regressions --- Makefile | 5 +- Source/API/PDFMultiDocumentGenerator.swift | 2 +- Source/API/Table/PDFTable.swift | 1 + .../EmptyDocumentTests.swift | 5 +- .../ExternalDocumentTests.swift | 91 +++++++++++++- .../MultiDocumentGenerationTests.swift | 52 ++++++++ .../TableHeaderPaginationTests.swift | 32 +++++ .../TextPaginationTests.swift | 59 +++++++++ .../Image/PDFImageObjectTests.swift | 34 +++++- .../Table/PDFTableHeaderPaginationTests.swift | 10 +- .../Table/PDFTableMergeUtilTests.swift | 62 ++++++++++ .../Layout/PDFLayoutIndentationsTests.swift | 19 ++- Tests/TPPDFTests/Layout/PDFLayoutTests.swift | 34 ++++-- .../TPPDFTests/List/PDFListObjectTests.swift | 46 ++++--- .../TPPDFTests/PDFDocumentObjectsTests.swift | 20 +-- .../PDFGeneratorGenerationTests.swift | 70 +++++++++-- Tests/TPPDFTests/Table/PDFTableTests.swift | 16 +++ .../Table/PDFTableValidatorTests.swift | 114 ++++++++++++++---- .../Utils/PDFCalculationsTests.swift | 43 +++++++ 19 files changed, 629 insertions(+), 86 deletions(-) create mode 100644 Tests/TPPDFIntegrationTests/MultiDocumentGenerationTests.swift create mode 100644 Tests/TPPDFIntegrationTests/TextPaginationTests.swift 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) + } } From 2f05276c693916a5b99aeae1f315d58b0dbd5fc4 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Sun, 11 Oct 2026 16:07:28 +0200 Subject: [PATCH 2/3] test: Add common-document regressions and fix layout defects --- Source/Internal/Group/PDFGroupObject.swift | 6 +- .../Text/PDFAttributedTextObject.swift | 14 +- .../CommonDocumentFixtures.swift | 219 ++++++++++++++++++ .../CommonDocumentTests.swift | 187 +++++++++++++++ .../ComplexLayoutRegressionTests.swift | 105 +++++++++ .../ExternalDocumentFailureTests.swift | 85 +++++++ .../LinkPaginationTests.swift | 77 ++++++ .../Internal/Group/PDFGroupObjectTests.swift | 71 ++++++ 8 files changed, 755 insertions(+), 9 deletions(-) create mode 100644 Tests/TPPDFIntegrationTests/CommonDocumentFixtures.swift create mode 100644 Tests/TPPDFIntegrationTests/CommonDocumentTests.swift create mode 100644 Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift create mode 100644 Tests/TPPDFIntegrationTests/ExternalDocumentFailureTests.swift create mode 100644 Tests/TPPDFIntegrationTests/LinkPaginationTests.swift create mode 100644 Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift diff --git a/Source/Internal/Group/PDFGroupObject.swift b/Source/Internal/Group/PDFGroupObject.swift index 26e1801a..06b3f2cd 100644 --- a/Source/Internal/Group/PDFGroupObject.swift +++ b/Source/Internal/Group/PDFGroupObject.swift @@ -49,6 +49,7 @@ class PDFGroupObject: PDFRenderObject { throw PDFError.copyingFailed } let cPadding = generator.currentPadding + defer { generator.currentPadding = cPadding } // Top Padding generator.layout.heights.add(padding.top, to: container) @@ -63,8 +64,9 @@ class PDFGroupObject: PDFRenderObject { [PDFLocatedRenderObject](), ] - // Set padding + // Top padding is already included in content height, so do not inset it a second time. generator.currentPadding = padding + generator.currentPadding.top = 0 // Calculate content for arg in objects { @@ -106,7 +108,6 @@ class PDFGroupObject: PDFRenderObject { } generator.layout.heights.add(padding.bottom, to: container) - generator.currentPadding = cPadding return result } @@ -124,6 +125,7 @@ class PDFGroupObject: PDFRenderObject { generator.layout.heights.add(padding.top, to: container) generator.currentPadding = padding + generator.currentPadding.top = 0 for (container, object) in objects { result += try object.calculate(generator: generator, container: container.contentContainer) diff --git a/Source/Internal/Text/PDFAttributedTextObject.swift b/Source/Internal/Text/PDFAttributedTextObject.swift index 9b14f8f7..668c706b 100644 --- a/Source/Internal/Text/PDFAttributedTextObject.swift +++ b/Source/Internal/Text/PDFAttributedTextObject.swift @@ -177,16 +177,16 @@ class PDFAttributedTextObject: PDFRenderObject { for (i, line) in lines.enumerated() { var ascent: CGFloat = 0 var descent: CGFloat = 0 - var leading: CGFloat = 0 - let typoBounds = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)) + let typoBounds = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, nil)) var lineOrigin = CGPoint.zero - CTFrameGetLineOrigins(frameRef, CFRange(location: lines.count - i - 1, length: 1), &lineOrigin) + CTFrameGetLineOrigins(frameRef, CFRange(location: i, length: 1), &lineOrigin) - let lineBounds = CGRect(x: frame.origin.x, - y: frame.origin.y + lineOrigin.y, + // Convert this line's baseline from Core Text's bottom-up frame to document coordinates. + let lineBounds = CGRect(x: frame.origin.x + lineOrigin.x, + y: frame.maxY - lineOrigin.y - ascent, width: typoBounds, - height: ascent + descent + leading) + height: ascent + descent) lineMetrics.append((line: line, bounds: lineBounds, range: CTLineGetStringRange(line))) } @@ -211,7 +211,7 @@ class PDFAttributedTextObject: PDFRenderObject { let endOffset = CTLineGetOffsetForStringIndex(metric.line, intersection.location + intersection.length, nil) let linkFrame = CGRect( - x: frame.origin.x + startOffset, + x: metric.bounds.minX + startOffset, y: metric.bounds.origin.y, width: endOffset - startOffset, height: metric.bounds.height diff --git a/Tests/TPPDFIntegrationTests/CommonDocumentFixtures.swift b/Tests/TPPDFIntegrationTests/CommonDocumentFixtures.swift new file mode 100644 index 00000000..a01637a3 --- /dev/null +++ b/Tests/TPPDFIntegrationTests/CommonDocumentFixtures.swift @@ -0,0 +1,219 @@ +// +// CommonDocumentFixtures.swift +// TPPDF +// +// Created by Philip Niedertscheider on 11.10.2026. +// Copyright © 2016-2026 techprimate GmbH. All rights reserved. +// + +import CoreGraphics +import Foundation +import Testing +@testable import TPPDF + +// Original, synthetic office documents. No third-party templates or personal data are used. +struct CommonDocumentFixture { + enum Kind: String, CaseIterable { + case report, invoice, resume, newsletter, letter, agenda + } + + enum Paper: CaseIterable { + case a4, letter, landscape + + var layout: PDFPageLayout { + switch self { + case .a4: return PDFPageFormat.a4.layout + case .letter: return PDFPageFormat.usLetter.layout + case .landscape: + var layout = PDFPageFormat.a4.layout + layout.size = PDFPageFormat.a4.landscapeSize + return layout + } + } + } + + let document: PDFDocument + let markerCounts: [String: Int] + let orderedSequences: [[String]] + let minimumPages: Int + var linkMarker: String? + var verticalTransitions: [(earlier: String, later: String)] = [] + var horizontalLimits: [String: (min: CGFloat, max: CGFloat)] = [:] + + static func make(_ kind: Kind, paper: Paper) throws -> CommonDocumentFixture { + let document = PDFDocument(layout: paper.layout) + document.layout.margin = EdgeInsets(top: 40, left: 40, bottom: 40, right: 40) + document.info = PDFInfo(title: "Common \(kind.rawValue)", author: "Fixture Team", subject: "Document regression") + document.set(font: Font.systemFont(ofSize: 12)) + document.set(.headerLeft, font: Font.systemFont(ofSize: 10)) + document.add(.headerLeft, text: "COMMON HEADER") + document.pagination = PDFPagination(container: .footerRight, style: .customClosure { page, total in + "PAGE\(page)OF\(total)" + }, textAttributes: [.font: Font.systemFont(ofSize: 10)]) + switch kind { + case .report: return report(document) + case .invoice: return invoice(document) + case .resume: return try resume(document) + case .newsletter: return newsletter(document) + case .letter: return letter(document) + case .agenda: return agenda(document) + } + } + + private static func report(_ document: PDFDocument) -> CommonDocumentFixture { + let heading = document.add(style: PDFTextStyle(name: "Report heading", font: Font.boldSystemFont(ofSize: 18), color: .black)) + let subheading = document.add(style: PDFTextStyle(name: "Report subheading", font: Font.boldSystemFont(ofSize: 14), color: .black)) + document.add(text: "REPORTTITLE Quarterly delivery report") + document.add(space: 12) + document.add(tableOfContent: PDFTableOfContent(styles: [heading, subheading], symbol: .none)) + document.createNewPage() + var counts = ["REPORTTITLE": 1, "RPTINTRO": 2, "RPTDETAIL": 2, "RPTRISKS": 2] + let sections = [("RPTINTRO", heading), ("RPTDETAIL", subheading), ("RPTRISKS", heading)] + var sequence: [String] = [] + for (sectionIndex, section) in sections.enumerated() { + document.add(textObject: PDFSimpleText(text: section.0 + " Delivery review", style: section.1)) + let markers = (0..<18).map { "RPT\(sectionIndex)P\($0)END" } + let paragraphs = markers.map { + $0 + " The team reviewed delivery quality, customer needs, and operating risks. " + + "The next milestone has a clear owner and measurable acceptance criteria." + } + document.add(text: paragraphs.joined(separator: "\n")) + document.add(space: 12) + for marker in markers { counts[marker] = 1 } + sequence += markers + } + return .init(document: document, markerCounts: counts, orderedSequences: [sequence], minimumPages: 3) + } + + private static func invoice(_ document: PDFDocument) -> CommonDocumentFixture { + document.add(text: "INVOICETITLE Services invoice") + let address = PDFGroup(backgroundColor: Color(white: 0.95, alpha: 1), padding: EdgeInsets(top: 8, left: 12, bottom: 8, right: 12)) + address.add(text: "BILLTO Fictional client department") + address.add(text: "INVOICEREF Reference 2026-001") + document.add(group: address) + let table = PDFTable(rows: 67, columns: 3) + table.widths = [0.55, 0.15, 0.3] + let style = PDFTableCellStyle(font: Font.systemFont(ofSize: 11)) + table.style = PDFTableStyle(rowHeaderCount: 0, columnHeaderCount: 1, footerCount: 1, + columnHeaderStyle: PDFTableCellStyle(colors: (.blue, .white), font: Font.boldSystemFont(ofSize: 11)), + footerStyle: style, contentStyle: style) + table.padding = 5 + table.margin = 2 + table.showHeadersOnEveryPage = true + table.shouldSplitCellsOnPageBreak = false + table.content = [["ITEM", "QTY", "AMOUNT"]] + + (0..<65).map { ["INV\($0)END Consulting service", "1", "100.00"] } + + [["INVOICETOTAL", "65", "6500.00"]] + document.add(table: table) + document.add(text: "PAYMENTTERMS Payment due in thirty days") + let markers = (0..<65).map { "INV\($0)END" } + var counts = Dictionary(uniqueKeysWithValues: markers.map { ($0, 1) }) + for marker in ["INVOICETITLE", "BILLTO", "INVOICEREF", "INVOICETOTAL", "PAYMENTTERMS"] { counts[marker] = 1 } + return .init(document: document, markerCounts: counts, orderedSequences: [markers + ["INVOICETOTAL", "PAYMENTTERMS"]], minimumPages: 2) + } + + private static func resume(_ document: PDFDocument) throws -> CommonDocumentFixture { + document.add(text: "RESUMETITLE Fictional candidate") + document.add(space: 12) + let section = PDFSection(columnWidths: [0.3, 0.7]) + section.columnMargin = 20 + section.columns[0].backgroundColor = Color(red: 0.9, green: 0.95, blue: 1, alpha: 1) + let profile = PDFGroup(padding: EdgeInsets(top: 8, left: 8, bottom: 8, right: 8)) + profile.add(.center, image: try image(size: CGSize(width: 60, height: 60))) + profile.add(text: "PROFILE Team-focused specialist") + let skills = PDFList(indentations: [(pre: 0, past: 12), (pre: 8, past: 12)]) + skills.addItem(PDFListItem(symbol: .dot, content: "SKILLONE Planning") + .addItem(PDFListItem(symbol: .dash, content: "SKILLTWO Delivery"))) + skills.addItem(PDFListItem(symbol: .dot, content: "SKILLTHREE Communication")) + profile.add(list: skills) + section.columns[0].add(group: profile) + let experiences = (0..<12).map { "JOB\($0)END" } + for marker in experiences { + section.columns[1].add(text: marker + " Project specialist") + section.columns[1].add(text: "Led a small team and reviewed delivery outcomes.") + section.columns[1].add(space: 10) + } + document.add(section: section) + document.add(text: "REFERENCES Available on request") + var counts = Dictionary(uniqueKeysWithValues: experiences.map { ($0, 1) }) + for marker in ["RESUMETITLE", "PROFILE", "SKILLONE", "SKILLTWO", "SKILLTHREE", "REFERENCES"] { counts[marker] = 1 } + let leftMax = 40 + (document.layout.width - 100) * 0.3 + let leftMarkers = ["PROFILE", "SKILLONE", "SKILLTWO", "SKILLTHREE"] + var limits = Dictionary(uniqueKeysWithValues: leftMarkers.map { ($0, (min: CGFloat(40), max: leftMax)) }) + for marker in experiences { limits[marker] = (min: leftMax + 20, max: document.layout.width - 40) } + return .init(document: document, markerCounts: counts, + orderedSequences: [["PROFILE", "SKILLONE", "SKILLTWO", "SKILLTHREE"], experiences + ["REFERENCES"]], minimumPages: 1, + verticalTransitions: [("JOB11END", "REFERENCES")], horizontalLimits: limits) + } + + private static func newsletter(_ document: PDFDocument) -> CommonDocumentFixture { + document.add(text: "NEWSLETTERTITLE Monthly team news") + document.add(space: 12) + document.enable(columns: 2, widths: [0.5, 0.5], spacings: [18]) + let markers = (0..<160).map { "NEWS\($0)END" } + document.add(text: markers.map { $0 + " Team update. Delivery remained stable and the next milestone is ready." }.joined(separator: "\n")) + document.disableColumns(addPageBreak: true) + document.add(text: "NEWSLETTEREND Next issue in one month") + var counts = Dictionary(uniqueKeysWithValues: markers.map { ($0, 1) }) + counts["NEWSLETTERTITLE"] = 1 + counts["NEWSLETTEREND"] = 1 + return .init(document: document, markerCounts: counts, orderedSequences: [markers + ["NEWSLETTEREND"]], minimumPages: 2) + } + + private static func letter(_ document: PDFDocument) -> CommonDocumentFixture { + document.add(text: "LETTERTITLE Project handover") + document.add(space: 12) + document.add(text: "SALUTATION Dear project team,") + let markers = (0..<12).map { "LETTER\($0)END" } + document.add(text: markers.map { $0 + " Please review the delivery notes and confirm the next milestone." }.joined(separator: "\n")) + let link = NSAttributedString(string: "PROJECTLINK Project resources", attributes: [ + .font: Font.systemFont(ofSize: 12), .link: "https://github.com/techprimate/TPPDF", + ]) + document.add(attributedText: link) + let signature = PDFGroup(allowsBreaks: false, padding: EdgeInsets(top: 10, left: 8, bottom: 10, right: 8)) + signature.add(text: "SIGNATURE Kind regards, Fixture Team") + document.add(group: signature) + var counts = Dictionary(uniqueKeysWithValues: markers.map { ($0, 1) }) + for marker in ["LETTERTITLE", "SALUTATION", "PROJECTLINK", "SIGNATURE"] { counts[marker] = 1 } + return .init(document: document, markerCounts: counts, + orderedSequences: [["SALUTATION"] + markers + ["PROJECTLINK", "SIGNATURE"]], minimumPages: 1, linkMarker: "PROJECTLINK") + } + + private static func agenda(_ document: PDFDocument) -> CommonDocumentFixture { + document.add(text: "AGENDATITLE Delivery workshop") + let table = PDFTable(rows: 12, columns: 3) + table.widths = [0.2, 0.5, 0.3] + let style = PDFTableCellStyle(font: Font.systemFont(ofSize: 11)) + table.style = PDFTableStyle(rowHeaderCount: 0, columnHeaderCount: 1, footerCount: 0, + columnHeaderStyle: style, contentStyle: style) + table.padding = 6 + table.content = [["SESSION", "TOPIC", "OWNER"]] + + (0..<11).map { ["SESSION\($0)END", "TOPIC\($0)END Delivery review", "Team"] } + table[rows: 1..<4, column: 0].merge() + document.add(table: table) + let actions = PDFList(indentations: [(pre: 0, past: 12), (pre: 10, past: 12)]) + actions.addItem(PDFListItem(symbol: .numbered(value: "1"), content: "ACTIONONE Confirm owner") + .addItem(PDFListItem(symbol: .dash, content: "ACTIONTWO Confirm deadline"))) + actions.addItem(PDFListItem(symbol: .numbered(value: "2"), content: "ACTIONTHREE Share notes")) + let summary = PDFGroup(allowsBreaks: false, backgroundColor: Color(white: 0.95, alpha: 1), + padding: EdgeInsets(top: 10, left: 12, bottom: 10, right: 12)) + summary.add(list: actions) + document.add(group: summary) + document.add(text: "AGENDAEND Workshop completed") + let topics = (0..<11).map { "TOPIC\($0)END" } + var counts = Dictionary(uniqueKeysWithValues: topics.map { ($0, 1) }) + for marker in ["AGENDATITLE", "SESSION0END", "ACTIONONE", "ACTIONTWO", "ACTIONTHREE", "AGENDAEND"] { counts[marker] = 1 } + for row in 4..<11 { counts["SESSION\(row)END"] = 1 } + return .init(document: document, markerCounts: counts, + orderedSequences: [topics, ["ACTIONONE", "ACTIONTWO", "ACTIONTHREE", "AGENDAEND"]], minimumPages: 1, + verticalTransitions: [("TOPIC10END", "ACTIONONE")]) + } + + static func image(size: CGSize, color: CGColor = CGColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)) throws -> PDFImage { + let context = try #require(PDFContextGraphics.createBitmapContext(size: size)) + context.setFillColor(color) + context.fill(CGRect(origin: .zero, size: size)) + let image = try #require(PDFContextGraphics.getImage(from: context, size: size)) + return PDFImage(image: image, size: size, options: .none) + } +} diff --git a/Tests/TPPDFIntegrationTests/CommonDocumentTests.swift b/Tests/TPPDFIntegrationTests/CommonDocumentTests.swift new file mode 100644 index 00000000..bf29217d --- /dev/null +++ b/Tests/TPPDFIntegrationTests/CommonDocumentTests.swift @@ -0,0 +1,187 @@ +// +// CommonDocumentTests.swift +// TPPDF +// +// Created by Philip Niedertscheider on 11.10.2026. +// Copyright © 2016-2026 techprimate GmbH. All rights reserved. +// + +import CoreGraphics +import Foundation +import Testing +@testable import TPPDF + +#if canImport(PDFKit) +import PDFKit + +struct CommonDocumentTests { + @Test(arguments: CommonDocumentFixture.Kind.allCases, [ + (paper: CommonDocumentFixture.Paper.a4, debug: false), (.a4, true), + (.letter, false), (.letter, true), (.landscape, false), (.landscape, true), + ]) + func officeDocumentsPreserveContentOrderPageFurnitureAndVisibleText( + kind: CommonDocumentFixture.Kind, scenario: (paper: CommonDocumentFixture.Paper, debug: Bool) + ) throws { + // Arrange + let fixture = try CommonDocumentFixture.make(kind, paper: scenario.paper) + let generator = PDFGenerator(document: fixture.document) + generator.debug = scenario.debug + + // Act + let data = try generator.generateData() + let outputDocument = PDFKit.PDFDocument(data: data) + let output = try #require(outputDocument) + + // Assert + #expect(generator.totalPages == output.pageCount) + try assertDocument(output, matches: fixture) + } + + @Test(arguments: CommonDocumentFixture.Kind.allCases) + func freshOfficeDocumentGeneratorsProduceStableLayoutAndContent(kind: CommonDocumentFixture.Kind) throws { + // Arrange + let fixture = try CommonDocumentFixture.make(kind, paper: .a4) + let independentFixture = try CommonDocumentFixture.make(kind, paper: .a4) + let firstGenerator = PDFGenerator(document: fixture.document) + let secondGenerator = PDFGenerator(document: independentFixture.document) + + // Act + let firstData = try firstGenerator.generateData() + let secondData = try secondGenerator.generateData() + let firstDocument = PDFKit.PDFDocument(data: firstData) + let secondDocument = PDFKit.PDFDocument(data: secondData) + let first = try #require(firstDocument) + let second = try #require(secondDocument) + + // Assert + #expect(second.pageCount == first.pageCount) + #expect(second.string == first.string) + #expect(secondGenerator.totalPages == second.pageCount) + try assertDocument(second, matches: fixture) + } + + @Test(arguments: [false, true]) + func officeDocumentPacketPreservesMemberOrderAndExplicitMetadata(writeFile: Bool) throws { + // Arrange + let letter = try CommonDocumentFixture.make(.letter, paper: .a4) + let invoice = try CommonDocumentFixture.make(.invoice, paper: .letter) + let generator = PDFMultiDocumentGenerator(documents: [letter.document, invoice.document]) + let info = PDFInfo(title: "Client packet", author: "Packet Team", subject: "Handover and invoice") + 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 + let text = try #require(output.string) + let endOfLetter = (text as NSString).range(of: "SIGNATURE") + let startOfInvoice = (text as NSString).range(of: "INVOICETITLE") + #expect(endOfLetter.location != NSNotFound) + #expect(startOfInvoice.location > endOfLetter.location) + for (marker, count) in letter.markerCounts.merging(invoice.markerCounts, uniquingKeysWith: +) { + #expect(text.components(separatedBy: marker).count - 1 == count, "Packet marker \(marker)") + } + let metadata = try #require(output.documentAttributes) + #expect(metadata[PDFDocumentAttribute.titleAttribute] as? String == "Client packet") + #expect(metadata[PDFDocumentAttribute.authorAttribute] as? String == "Packet Team") + #expect(metadata[PDFDocumentAttribute.subjectAttribute] as? String == "Handover and invoice") + } + + private func assertDocument(_ output: PDFKit.PDFDocument, matches fixture: CommonDocumentFixture) throws { + #expect(output.pageCount >= fixture.minimumPages) + let allText = try #require(output.string) + let markers = fixture.markerCounts.keys.sorted() + for marker in markers { + #expect(allText.components(separatedBy: marker).count - 1 == fixture.markerCounts[marker], "Marker \(marker)") + } + for sequence in fixture.orderedSequences { + let positions = sequence.map { (allText as NSString).range(of: $0).location } + #expect(!positions.contains(NSNotFound)) + #expect(zip(positions, positions.dropFirst()).allSatisfy { $0 < $1 }, "Sequence \(sequence) at \(positions)") + } + let attributes = try #require(output.documentAttributes) + #expect(attributes[PDFDocumentAttribute.titleAttribute] as? String == fixture.document.info.title) + var markerPositions: [String: (page: Int, bounds: CGRect)] = [:] + for index in 0..= later.bounds.maxY, "\(transition.later) must stay below \(transition.earlier)") + } + } + } + + private func assertPage( + _ page: PDFPage, index: Int, totalPages: Int, fixture: CommonDocumentFixture, markers: [String] + ) throws -> [String: CGRect] { + var markerPositions: [String: CGRect] = [:] + let text = try #require(page.string) + #expect(text.components(separatedBy: "COMMON HEADER").count - 1 == 1, "Header on page \(index)") + #expect(text.components(separatedBy: "PAGE\(index + 1)OF\(totalPages)").count - 1 == 1) + let pageBounds = page.bounds(for: .mediaBox) + #expect(abs(pageBounds.width - fixture.document.layout.width) < 0.01) + #expect(abs(pageBounds.height - fixture.document.layout.height) < 0.01) + let pageMarkers = markers.filter { text.contains($0) } + #expect(!pageMarkers.isEmpty, "Page \(index) must have body content") + for marker in pageMarkers { + let range = (text as NSString).range(of: marker) + let selection = try #require(page.selection(for: range)) + let bounds = selection.bounds(for: page) + markerPositions[marker] = bounds + let margin = fixture.document.layout.margin + let contentBounds = CGRect(x: margin.left, y: margin.bottom, + width: pageBounds.width - margin.left - margin.right, + height: pageBounds.height - margin.top - margin.bottom) + #expect(contentBounds.insetBy(dx: -1, dy: -2).contains(bounds), "Bounds for \(marker) on page \(index): \(bounds)") + if let limits = fixture.horizontalLimits[marker] { + #expect(bounds.minX >= limits.min - 0.01, "Left column limit for \(marker)") + #expect(bounds.maxX <= limits.max + 0.01, "Right column limit for \(marker)") + } + } + for marker in [pageMarkers.first, pageMarkers.last].compactMap({ $0 }) { + let selection = try #require(page.selection(for: (text as NSString).range(of: marker))) + #expect(try darkPixels(on: page, in: selection.bounds(for: page)) > 5, "Visible ink for \(marker) on page \(index)") + } + if let marker = fixture.linkMarker, text.contains(marker) { + let links = page.annotations.filter { $0.url?.absoluteString == "https://github.com/techprimate/TPPDF" } + let selection = try #require(page.selection(for: (text as NSString).range(of: marker))) + #expect(!links.isEmpty) + #expect(links.contains { $0.bounds.intersects(selection.bounds(for: page)) }) + } + return markerPositions + } + + private func darkPixels(on page: PDFPage, in bounds: CGRect) throws -> Int { + let crop = bounds.insetBy(dx: -2, dy: -2).integral + let context = try #require(CGContext( + data: nil, width: Int(crop.width), height: Int(crop.height), bitsPerComponent: 8, bytesPerRow: Int(crop.width) * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(gray: 1, alpha: 1) + context.fill(CGRect(origin: .zero, size: crop.size)) + context.translateBy(x: -crop.minX, y: -crop.minY) + page.draw(with: .mediaBox, to: context) + let bytes = try #require(context.data).assumingMemoryBound(to: UInt8.self) + return stride(from: 0, to: context.bytesPerRow * context.height, by: 4).filter { + Int(bytes[$0]) + Int(bytes[$0 + 1]) + Int(bytes[$0 + 2]) < 300 + }.count + } +} +#endif diff --git a/Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift b/Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift new file mode 100644 index 00000000..b0d3273f --- /dev/null +++ b/Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift @@ -0,0 +1,105 @@ +// +// ComplexLayoutRegressionTests.swift +// TPPDF +// +// Created by Philip Niedertscheider on 11.10.2026. +// Copyright © 2016-2026 techprimate GmbH. All rights reserved. +// + +import CoreGraphics +import Foundation +import Testing +@testable import TPPDF + +#if canImport(PDFKit) +import PDFKit + +struct ComplexLayoutRegressionTests { + @Test func imageDelegateReceivesFinalFrameAndCanReplacePixelsBeforeDrawing() throws { + // Arrange + let document = TPPDF.PDFDocument(layout: PDFPageLayout( + size: CGSize(width: 200, height: 180), margin: EdgeInsets(top: 20, left: 20, bottom: 20, right: 20), space: (0, 0) + )) + let image = try CommonDocumentFixture.image(size: CGSize(width: 30, height: 30)) + let replacement = try CommonDocumentFixture.image(size: CGSize(width: 30, height: 30), + color: CGColor(red: 1, green: 0, blue: 0, alpha: 1)) + document.add(image: image) + let delegate = ReplacingImageDelegate(replacement: replacement.image) + let generator = PDFGenerator(document: document) + generator.delegate = delegate + + // Act + let data = try generator.generateData() + let outputDocument = PDFKit.PDFDocument(data: data) + let output = try #require(outputDocument) + let page = try #require(output.page(at: 0)) + let context = try #require(CGContext(data: nil, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.translateBy(x: -35, y: -145) + page.draw(with: .mediaBox, to: context) + let pixels = try #require(context.data).assumingMemoryBound(to: UInt8.self) + + // Assert + #expect(output.pageCount == 1) + try #require(delegate.images.count == 1) + #expect(delegate.images[0] === image) + #expect(delegate.frames == [CGRect(x: 20, y: 20, width: 30, height: 30)]) + // Color conversion can change channel values, but the replacement must be red rather than the original blue. + #expect(Int(pixels[0]) > Int(pixels[1]) + 128) + #expect(Int(pixels[0]) > Int(pixels[2]) + 128) + } + + private final class ReplacingImageDelegate: PDFGeneratorImageDelegate { + let replacement: Image + var images: [PDFImage] = [] + var frames: [CGRect] = [] + + init(replacement: Image) { + self.replacement = replacement + } + + func generator(willBeginDrawingImage image: PDFImage, with _: PDFContext, in frame: CGRect) { + images.append(image) + frames.append(frame) + image.image = replacement + } + } + + @Test(arguments: [false, true], [false, true]) + func unbreakableGroupDoesNotIndentFollowingContentWhenMoved(moveToNextPage: Bool, debug: Bool) throws { + // Arrange + let document = TPPDF.PDFDocument(layout: PDFPageLayout( + size: CGSize(width: 200, height: 240), margin: EdgeInsets(top: 20, left: 20, bottom: 20, right: 20), space: (0, 0) + )) + document.add(text: "BEFORE") + if moveToNextPage { document.add(space: 150) } + let group = PDFGroup(allowsBreaks: false, backgroundColor: Color(white: 0.95, alpha: 1), + padding: EdgeInsets(top: 10, left: 24, bottom: 10, right: 32)) + group.add(text: "GROUP") + document.add(group: group) + document.add(text: "AFTER") + 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 == (moveToNextPage ? 2 : 1)) + #expect(generator.totalPages == output.pageCount) + let page = try #require(output.page(at: output.pageCount - 1)) + let text = try #require(page.string) + let groupRange = (text as NSString).range(of: "GROUP") + let afterRange = (text as NSString).range(of: "AFTER") + try #require(groupRange.location != NSNotFound) + try #require(afterRange.location != NSNotFound) + let groupSelection = try #require(page.selection(for: groupRange)) + let afterSelection = try #require(page.selection(for: afterRange)) + #expect(abs(afterSelection.bounds(for: page).minX - 20) < 0.01) + #expect(afterSelection.bounds(for: page).maxY <= groupSelection.bounds(for: page).minY) + #expect(generator.currentPadding == .zero) + } +} +#endif diff --git a/Tests/TPPDFIntegrationTests/ExternalDocumentFailureTests.swift b/Tests/TPPDFIntegrationTests/ExternalDocumentFailureTests.swift new file mode 100644 index 00000000..d4e72b1f --- /dev/null +++ b/Tests/TPPDFIntegrationTests/ExternalDocumentFailureTests.swift @@ -0,0 +1,85 @@ +// +// ExternalDocumentFailureTests.swift +// TPPDF +// +// Created by Philip Niedertscheider on 11.10.2026. +// Copyright © 2016-2026 techprimate GmbH. All rights reserved. +// + +import CoreGraphics +import Foundation +import Testing +@testable import TPPDF + +#if canImport(PDFKit) +import PDFKit + +struct ExternalDocumentFailureTests { + @Test(arguments: ["Not a PDF", "%PDF-1.7\n"]) + func invalidExternalFileThrowsAndCanBeReplacedForAFreshGeneration(invalidContent: String) throws { + // Arrange + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".pdf") + defer { try? FileManager.default.removeItem(at: url) } + try Data(invalidContent.utf8).write(to: url) + let document = TPPDF.PDFDocument(format: .a4) + document.add(text: "BEFORE") + document.add(externalDocument: .init(url: url)) + let failedGenerator = PDFGenerator(document: document) + + // Act + let error = #expect(throws: PDFError.self) { + try failedGenerator.generateData() + } + let replacement = TPPDF.PDFDocument(format: .a4) + replacement.add(text: "RECOVERED") + let replacementData = try PDFGenerator(document: replacement).generateData() + try replacementData.write(to: url) + let data = try PDFGenerator(document: document).generateData() + let outputDocument = PDFKit.PDFDocument(data: data) + let output = try #require(outputDocument) + + // Assert + guard case let .externalDocumentURLInvalid(actualURL)? = error else { + Issue.record("Expected externalDocumentURLInvalid, got \(String(describing: error))") + return + } + #expect(actualURL == url) + #expect(output.pageCount == 2) + #expect(output.page(at: 0)?.string?.contains("BEFORE") == true) + #expect(output.page(at: 1)?.string?.contains("RECOVERED") == true) + } + + #if os(macOS) && swift(>=6.2) + @Test func lockedExternalDocumentReturnsAnErrorWithoutTerminatingProcess() async { + // Arrange + let expectedExit = ExitTest.Condition.success + + // Act + let result = await #expect(processExitsWith: expectedExit) { + let source = TPPDF.PDFDocument(format: .a4) + source.info = PDFInfo(ownerPassword: "fixture-owner", userPassword: "fixture-reader") + source.add(text: "LOCKED") + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".pdf") + defer { try? FileManager.default.removeItem(at: url) } + try PDFGenerator(document: source).generate(to: url) + let cgPDF = try #require(CGPDFDocument(url as CFURL)) + #expect(cgPDF.isEncrypted) + #expect(!cgPDF.isUnlocked) + let document = TPPDF.PDFDocument(format: .a4) + document.add(externalDocument: .init(url: url)) + let error = #expect(throws: PDFError.self) { + try PDFGenerator(document: document).generateData() + } + guard case let .pageOutOfBounds(index)? = error else { + Issue.record("Expected pageOutOfBounds for inaccessible content, got \(String(describing: error))") + return + } + #expect(index == 1) + } + + // Assert + #expect(result != nil) + } + #endif +} +#endif diff --git a/Tests/TPPDFIntegrationTests/LinkPaginationTests.swift b/Tests/TPPDFIntegrationTests/LinkPaginationTests.swift new file mode 100644 index 00000000..8bd9ef59 --- /dev/null +++ b/Tests/TPPDFIntegrationTests/LinkPaginationTests.swift @@ -0,0 +1,77 @@ +// +// LinkPaginationTests.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 LinkPaginationTests { + @Test(arguments: [0, 1, 2], [ + (mixedFonts: false, debug: false), (false, true), (true, false), (true, true), + ]) + func wrappedLinkAnnotationsCoverLinkedTextButNotItsUnlinkedNeighbors( + alignmentIndex: Int, scenario: (mixedFonts: Bool, debug: Bool) + ) throws { + // Arrange + let document = TPPDF.PDFDocument(layout: PDFPageLayout( + size: CGSize(width: 220, height: 180), margin: EdgeInsets(top: 20, left: 20, bottom: 20, right: 20), space: (0, 0) + )) + let markers = (0..<40).map { "LINK\($0)END" } + let linkedText = markers.joined(separator: " ") + let text = NSMutableAttributedString(string: "PREFIX🙂\n" + linkedText + "\nAFTERLINK", + attributes: [.font: Font.systemFont(ofSize: 12)]) + let url = "https://github.com/techprimate/TPPDF" + text.addAttribute(.link, value: url, range: (text.string as NSString).range(of: linkedText)) + if scenario.mixedFonts { + text.addAttribute(.font, value: Font.systemFont(ofSize: 20), range: (text.string as NSString).range(of: "LINK10END")) + } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = [.left, .center, .right][alignmentIndex] + text.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.length)) + document.add(attributedText: text) + let generator = PDFGenerator(document: document) + generator.debug = scenario.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) + let allText = try #require(output.string) + for marker in markers { #expect(allText.components(separatedBy: marker).count - 1 == 1) } + var annotationCount = 0 + for index in 0.. 1) + } +} +#endif diff --git a/Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift b/Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift new file mode 100644 index 00000000..f0dd1342 --- /dev/null +++ b/Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift @@ -0,0 +1,71 @@ +// +// PDFGroupObjectTests.swift +// TPPDF +// +// Created by Philip Niedertscheider on 11.10.2026. +// Copyright © 2016-2026 techprimate GmbH. All rights reserved. +// + +import CoreGraphics +import Testing +@testable import TPPDF + +struct PDFGroupObjectTests { + @Test(arguments: [false, true], [false, true]) + func calculatingGroupRestoresEnclosingPaddingBeforeFollowingText(moveToNextPage: Bool, enclosingPadding: Bool) throws { + // Arrange + let document = PDFDocument(layout: PDFPageLayout( + size: CGSize(width: 200, height: 240), margin: EdgeInsets(top: 20, left: 20, bottom: 20, right: 20), space: (0, 0) + )) + let generator = PDFGenerator(document: document) + let originalPadding = enclosingPadding ? EdgeInsets(top: 3, left: 4, bottom: 5, right: 6) : .zero + generator.currentPadding = originalPadding + if moveToNextPage { + let height = PDFCalculations.calculateAvailableFrameHeight(for: generator, in: .contentLeft) + generator.layout.heights.content = height - 10 + } + let object = PDFGroupObject( + objects: [(.left, PDFAttributedTextObject(simpleText: PDFSimpleText(text: "GROUP")))], + allowsBreaks: false, isFullPage: false, backgroundColor: nil, backgroundImage: nil, backgroundShape: nil, + outline: .none, padding: EdgeInsets(top: 10, left: 24, bottom: 10, right: 32) + ) + let following = PDFAttributedTextObject(simpleText: PDFSimpleText(text: "AFTER")) + + // Act + let result = try object.calculate(generator: generator, container: .contentLeft) + let restoredPadding = generator.currentPadding + _ = try following.calculate(generator: generator, container: .contentLeft) + + // Assert + #expect(result.contains { $0.1 is PDFPageBreakObject } == moveToNextPage) + #expect(restoredPadding == originalPadding) + #expect(generator.currentPadding == originalPadding) + #expect(following.frame.minX == 20 + originalPadding.left) + #expect(object.frame.minY == 20) + #expect(following.frame.minY >= object.frame.maxY) + } + + @Test func failedGroupCalculationRestoresEnclosingPadding() { + // Arrange + let generator = PDFGenerator(document: PDFDocument(format: .a4)) + let originalPadding = EdgeInsets(top: 1, left: 2, bottom: 3, right: 4) + generator.currentPadding = originalPadding + let group = PDFGroupObject( + objects: [(.left, PDFTableObject(table: PDFTable(rows: 0, columns: 0)))], + allowsBreaks: false, isFullPage: false, backgroundColor: nil, backgroundImage: nil, backgroundShape: nil, + outline: .none, padding: EdgeInsets(top: 10, left: 20, bottom: 30, right: 40) + ) + + // Act + let error = #expect(throws: PDFError.self) { + try group.calculate(generator: generator, container: .contentLeft) + } + + // Assert + guard case .tableIsEmpty? = error else { + Issue.record("Expected tableIsEmpty, got \(String(describing: error))") + return + } + #expect(generator.currentPadding == originalPadding) + } +} From fde1a283835ec73cb30c838e9f034eadfe993271 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Sun, 11 Oct 2026 17:27:49 +0200 Subject: [PATCH 3/3] fix(group): Preserve padding across continuation pages --- Source/Internal/Group/PDFGroupObject.swift | 14 ++--- .../ComplexLayoutRegressionTests.swift | 63 +++++++++++++++++++ .../Internal/Group/PDFGroupObjectTests.swift | 42 +++++++++++++ 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/Source/Internal/Group/PDFGroupObject.swift b/Source/Internal/Group/PDFGroupObject.swift index 06b3f2cd..17d25b46 100644 --- a/Source/Internal/Group/PDFGroupObject.swift +++ b/Source/Internal/Group/PDFGroupObject.swift @@ -51,8 +51,8 @@ class PDFGroupObject: PDFRenderObject { let cPadding = generator.currentPadding defer { generator.currentPadding = cPadding } - // Top Padding - generator.layout.heights.add(padding.top, to: container) + // Keep the inset active when child calculations reset heights at page or column breaks. + generator.currentPadding = padding // Fix if not enough space left var result: [PDFLocatedRenderObject] = [] @@ -64,10 +64,6 @@ class PDFGroupObject: PDFRenderObject { [PDFLocatedRenderObject](), ] - // Top padding is already included in content height, so do not inset it a second time. - generator.currentPadding = padding - generator.currentPadding.top = 0 - // Calculate content for arg in objects { let (groupContainer, object) = arg @@ -107,7 +103,7 @@ class PDFGroupObject: PDFRenderObject { result += grouped } - generator.layout.heights.add(padding.bottom, to: container) + generator.layout.heights.add(padding.top + padding.bottom, to: container) return result } @@ -123,16 +119,14 @@ class PDFGroupObject: PDFRenderObject { result += try pbObj.calculate(generator: generator, container: container) result += [(container, self)] - generator.layout.heights.add(padding.top, to: container) generator.currentPadding = padding - generator.currentPadding.top = 0 for (container, object) in objects { result += try object.calculate(generator: generator, container: container.contentContainer) } frame = isFullPage ? calculateBoundsFrame(generator: generator) : calculateFrame(objects: result) - generator.layout.heights.add(padding.bottom, to: container) + generator.layout.heights.add(padding.top + padding.bottom, to: container) return result } diff --git a/Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift b/Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift index b0d3273f..dd4f6e7a 100644 --- a/Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift +++ b/Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift @@ -49,6 +49,69 @@ struct ComplexLayoutRegressionTests { #expect(Int(pixels[0]) > Int(pixels[2]) + 128) } + @Test(arguments: [CGFloat(0), 25], [false, true]) + func breakingGroupKeepsContinuationPaddingAndDoesNotCoverPageHeaders(topPadding: CGFloat, debug: Bool) throws { + // Arrange + let document = TPPDF.PDFDocument(layout: PDFPageLayout( + size: CGSize(width: 200, height: 240), margin: EdgeInsets(top: 20, left: 20, bottom: 20, right: 20), space: (5, 5) + )) + document.set(font: Font.systemFont(ofSize: 12)) + document.set(.headerLeft, font: Font.systemFont(ofSize: 12)) + document.set(.footerLeft, font: Font.systemFont(ofSize: 10)) + document.add(.headerLeft, text: "HEADER") + document.add(.footerLeft, text: "FOOTER") + document.add(text: "BEFORE") + let markers = (0..<40).map { "GROUPLINE\($0)END" } + let group = PDFGroup(allowsBreaks: true, backgroundColor: Color(white: 0.95, alpha: 1), + padding: EdgeInsets(top: topPadding, left: 8, bottom: 10, right: 8)) + group.add(text: markers.joined(separator: "\n")) + document.add(group: group) + document.add(text: "AFTER") + 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 > 2) + #expect(generator.totalPages == output.pageCount) + let allText = try #require(output.string) + for marker in ["BEFORE"] + markers + ["AFTER"] { + #expect(allText.components(separatedBy: marker).count - 1 == 1) + } + for index in 0..= topPadding) + #expect(try blackPixels(on: page, in: header.bounds(for: page)) > 5, "Header must remain visible on page \(index)") + } + #expect(generator.currentPadding == .zero) + } + + private func blackPixels(on page: PDFPage, in bounds: CGRect) throws -> Int { + let crop = bounds.integral + let context = try #require(CGContext( + data: nil, width: Int(crop.width), height: Int(crop.height), bitsPerComponent: 8, bytesPerRow: Int(crop.width) * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(gray: 1, alpha: 1) + context.fill(CGRect(origin: .zero, size: crop.size)) + context.translateBy(x: -crop.minX, y: -crop.minY) + page.draw(with: .mediaBox, to: context) + let bytes = try #require(context.data).assumingMemoryBound(to: UInt8.self) + return stride(from: 0, to: context.bytesPerRow * context.height, by: 4).filter { + Int(bytes[$0]) + Int(bytes[$0 + 1]) + Int(bytes[$0 + 2]) < 150 + }.count + } + private final class ReplacingImageDelegate: PDFGeneratorImageDelegate { let replacement: Image var images: [PDFImage] = [] diff --git a/Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift b/Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift index f0dd1342..1f329793 100644 --- a/Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift +++ b/Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift @@ -7,6 +7,7 @@ // import CoreGraphics +import Foundation import Testing @testable import TPPDF @@ -45,6 +46,47 @@ struct PDFGroupObjectTests { #expect(following.frame.minY >= object.frame.maxY) } + @Test(arguments: [CGFloat(0), 10, 25], [false, true]) + func breakableGroupPreservesPaddingAcrossPagesAndWrappedColumns(topPadding: CGFloat, wrapColumns: Bool) throws { + // Arrange + let document = PDFDocument(layout: PDFPageLayout( + size: CGSize(width: 200, height: 180), margin: EdgeInsets(top: 20, left: 20, bottom: 20, right: 20), space: (5, 5) + )) + let generator = PDFGenerator(document: document) + generator.layout.heights.header[.headerLeft] = 20 + generator.layout.heights.footer[.footerLeft] = 15 + if wrapColumns { + _ = try PDFColumnWrapSectionObject(columns: 2, widths: [0.5, 0.5], spacings: [10]) + .calculate(generator: generator, container: .contentLeft) + } + let source = NSAttributedString(string: (0..<40).map { "LINE\($0)END" }.joined(separator: "\n"), + attributes: [.font: Font.systemFont(ofSize: 10)]) + let object = PDFGroupObject( + objects: [(.left, PDFAttributedTextObject(attributedText: PDFAttributedText(text: source)))], + allowsBreaks: true, isFullPage: false, backgroundColor: .blue, backgroundImage: nil, backgroundShape: nil, + outline: .none, padding: EdgeInsets(top: topPadding, left: 6, bottom: 10, right: 6) + ) + + // Act + let result = try object.calculate(generator: generator, container: .contentLeft) + let groups = result.compactMap { $0.1 as? PDFGroupObject } + let texts = result.compactMap { $0.1 as? PDFAttributedTextObject } + + // Assert + try #require(groups.count > 1) + try #require(groups.count == texts.count) + #expect(result.contains { ($0.1 as? PDFPageBreakObject)?.stayOnSamePage == true } == wrapColumns) + for (group, text) in zip(groups, texts) { + #expect(group.frame.minY == 45) + #expect(text.frame.minY == 45 + topPadding) + #expect(text.frame.minY - group.frame.minY == topPadding) + #expect(group.frame.maxY <= 140) + } + let renderedText = texts.map { $0.attributedString.string }.joined() + #expect(renderedText == source.string) + #expect(generator.currentPadding == .zero) + } + @Test func failedGroupCalculationRestoresEnclosingPadding() { // Arrange let generator = PDFGenerator(document: PDFDocument(format: .a4))