diff --git a/Source/Internal/Group/PDFGroupObject.swift b/Source/Internal/Group/PDFGroupObject.swift index 26e1801a..17d25b46 100644 --- a/Source/Internal/Group/PDFGroupObject.swift +++ b/Source/Internal/Group/PDFGroupObject.swift @@ -49,9 +49,10 @@ class PDFGroupObject: PDFRenderObject { throw PDFError.copyingFailed } 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] = [] @@ -63,9 +64,6 @@ class PDFGroupObject: PDFRenderObject { [PDFLocatedRenderObject](), ] - // Set padding - generator.currentPadding = padding - // Calculate content for arg in objects { let (groupContainer, object) = arg @@ -105,8 +103,7 @@ class PDFGroupObject: PDFRenderObject { result += grouped } - generator.layout.heights.add(padding.bottom, to: container) - generator.currentPadding = cPadding + generator.layout.heights.add(padding.top + padding.bottom, to: container) return result } @@ -122,7 +119,6 @@ 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 for (container, object) in objects { @@ -130,7 +126,7 @@ class PDFGroupObject: PDFRenderObject { } 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/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..dd4f6e7a --- /dev/null +++ b/Tests/TPPDFIntegrationTests/ComplexLayoutRegressionTests.swift @@ -0,0 +1,168 @@ +// +// 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) + } + + @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] = [] + 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..1f329793 --- /dev/null +++ b/Tests/TPPDFTests/Internal/Group/PDFGroupObjectTests.swift @@ -0,0 +1,113 @@ +// +// PDFGroupObjectTests.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 + +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(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)) + 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) + } +}