Skip to content

Commit d7aa56a

Browse files
committed
fix(mermaid): increase PDF export height limits for complex diagrams
Increased rendering resources to better accommodate complex diagrams: - Initial height: 1000px -> 3000px - Maximum height: 2000px -> 4000px - Layout cycles: 3 -> 5 with longer delays (0.05s -> 0.1s) - Added stability detection and debug logging - Fixed Swift 6 concurrency issues Most diagrams now export successfully. Very large diagrams may still need additional investigation.
1 parent 3e42571 commit d7aa56a

3 files changed

Lines changed: 95 additions & 31 deletions

File tree

Sources/UserInterface/Chat/MarkdownASTToNSAttributedString.swift

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -212,33 +212,63 @@ class MarkdownASTToNSAttributedString {
212212
return createFallbackCodeBlock(code: code)
213213
}
214214

215+
// Render at higher resolution (700px) for better quality
216+
// Then scale down to PDF page width (550px) for proper fitting
217+
let renderWidth: CGFloat = 700
218+
let targetWidth: CGFloat = 550 // PDF page usable width
219+
215220
// Create SwiftUI diagram view WITH pre-parsed diagram
216221
let diagramView = MermaidDiagramView(code: code, diagram: parsedDiagram, showBackground: false)
217-
.frame(width: 700, alignment: .leading)
222+
.frame(width: renderWidth, alignment: .leading)
218223

219224
// Use bitmapImageRepForCachingDisplay - most reliable for offscreen rendering
220225
var capturedImage: NSImage?
221226

222-
let renderWithBitmap: () -> NSImage? = {
227+
let renderWithBitmap: () -> NSImage? = { [self] in
223228
let hostingView = NSHostingView(rootView: diagramView)
224-
hostingView.frame = NSRect(x: 0, y: 0, width: 700, height: 1000)
229+
// INCREASED: Use 3000px initial height (was 1000px) for complex diagrams
230+
hostingView.frame = NSRect(x: 0, y: 0, width: renderWidth, height: 3000)
225231

226232
// Force layout multiple times for SwiftUI to properly render
227-
for _ in 0..<3 {
233+
// INCREASED: 5 cycles with longer delays (was 3 cycles × 0.05s)
234+
var lastHeight: CGFloat = 0
235+
for cycle in 0..<5 {
228236
hostingView.layout()
229237
hostingView.layoutSubtreeIfNeeded()
230-
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05))
238+
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1))
239+
240+
// Check for stabilization
241+
let currentSize = hostingView.fittingSize
242+
self.logger.debug("PDF render cycle \(cycle): fittingSize=\(currentSize.width)×\(currentSize.height)")
243+
244+
if cycle > 2 && currentSize.height > 100 {
245+
let heightChange = abs(currentSize.height - lastHeight)
246+
let changePercent = heightChange / max(currentSize.height, 1) * 100
247+
if changePercent < 5 {
248+
self.logger.info("PDF render stabilized at cycle \(cycle): height=\(currentSize.height)")
249+
break
250+
}
251+
}
252+
lastHeight = currentSize.height
231253
}
232254

233255
// Get actual size after layout
234256
let actualSize = hostingView.fittingSize
235-
let finalHeight = max(min(actualSize.height, 2000), 100)
236-
hostingView.frame = NSRect(x: 0, y: 0, width: 700, height: finalHeight)
257+
self.logger.info("PDF render fittingSize: \(actualSize.width)×\(actualSize.height)")
258+
259+
// INCREASED: Allow up to 4000px height (was 2000px) for very complex diagrams
260+
let finalHeight = max(min(actualSize.height, 4000), 100)
261+
let finalWidth = max(actualSize.width, renderWidth)
262+
263+
hostingView.frame = NSRect(x: 0, y: 0, width: finalWidth, height: finalHeight)
264+
self.logger.info("PDF render final frame: \(finalWidth)×\(finalHeight)")
265+
237266
hostingView.layout()
238267
hostingView.layoutSubtreeIfNeeded()
239268

240269
// Render using bitmapImageRepForCachingDisplay (reliable for offscreen)
241270
guard let bitmapRep = hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds) else {
271+
self.logger.error("Failed to create bitmap representation for PDF")
242272
return nil
243273
}
244274
hostingView.cacheDisplay(in: hostingView.bounds, to: bitmapRep)
@@ -269,19 +299,15 @@ class MarkdownASTToNSAttributedString {
269299
let attachment = NSTextAttachment()
270300
attachment.image = nsImage
271301

272-
// Scale image to fit page width (max 550 to account for margins)
273-
let maxWidth: CGFloat = 550
274-
if nsImage.size.width > maxWidth {
275-
let scale = maxWidth / nsImage.size.width
276-
attachment.bounds = CGRect(
277-
x: 0,
278-
y: 0,
279-
width: nsImage.size.width * scale,
280-
height: nsImage.size.height * scale
281-
)
282-
} else {
283-
attachment.bounds = CGRect(origin: .zero, size: nsImage.size)
284-
}
302+
// Scale image to fit PDF page width
303+
// Render at 700px for quality, scale to 550px for page fit
304+
let scale = targetWidth / nsImage.size.width
305+
attachment.bounds = CGRect(
306+
x: 0,
307+
y: 0,
308+
width: nsImage.size.width * scale,
309+
height: nsImage.size.height * scale
310+
)
285311

286312
logger.debug("Mermaid NSTextAttachment created - hasImage: \(attachment.image != nil), bounds: \(attachment.bounds)")
287313

Sources/UserInterface/Chat/Mermaid/MermaidImageRenderer.swift

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,28 @@
33

44
import SwiftUI
55
import AppKit
6+
import Logging
67

78
/// Utility to render SwiftUI views as NSImages for PDF/print export
89
struct MermaidImageRenderer {
10+
private static let logger = Logger(label: "com.sam.mermaid.imagerenderer")
11+
912
/// Render a MermaidDiagramView to an NSImage
1013
/// - Parameters:
1114
/// - code: The mermaid code to render
1215
/// - width: Desired width of the rendered image
1316
/// - Returns: Rendered NSImage, or nil if rendering fails
1417
@MainActor
1518
static func renderDiagram(code: String, width: CGFloat = 600) -> NSImage? {
19+
logger.info("Starting PDF render: width=\(width)")
20+
1621
// Parse the diagram first to verify it's valid
1722
let parser = MermaidParser()
1823
let diagram = parser.parse(code)
1924

2025
// Skip rendering if it's unsupported
2126
if case .unsupported = diagram {
27+
logger.warning("Skipping unsupported diagram type")
2228
return nil
2329
}
2430

@@ -31,12 +37,20 @@ struct MermaidImageRenderer {
3137
.frame(width: width)
3238
.padding()
3339

40+
// INCREASED: Use much larger initial dimensions for complex diagrams
41+
// Old: 550px wide × 1500px tall → insufficient for complex flowcharts
42+
// New: 800px wide × 3000px tall → more room for layout calculations
43+
let initialWidth = width + 40
44+
let initialHeight: CGFloat = 3000
45+
3446
// Create NSHostingView to convert SwiftUI to NSView
3547
let hostingView = NSHostingView(rootView: containerView)
36-
hostingView.frame = CGRect(x: 0, y: 0, width: width + 40, height: 1500) // Generous initial height
48+
hostingView.frame = CGRect(x: 0, y: 0, width: initialWidth, height: initialHeight)
49+
logger.debug("Initial frame: width=\(initialWidth), height=\(initialHeight)")
3750

3851
// Force layout with longer delays for complex diagrams
3952
// Complex flowcharts need time for edge routing and obstacle avoidance calculations
53+
var lastHeight: CGFloat = 0
4054
for cycle in 0..<5 {
4155
hostingView.layout()
4256
hostingView.layoutSubtreeIfNeeded()
@@ -46,20 +60,37 @@ struct MermaidImageRenderer {
4660

4761
// Check if rendering is stabilizing (fittingSize not changing much)
4862
let currentSize = hostingView.fittingSize
63+
logger.debug("Layout cycle \(cycle): fittingSize=\(currentSize.width)×\(currentSize.height)")
64+
4965
if cycle > 2 && currentSize.height > 100 {
50-
// If we have a reasonable height after 3 cycles, we're probably done
51-
// One more cycle to be sure
52-
hostingView.layout()
53-
hostingView.layoutSubtreeIfNeeded()
54-
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.15))
55-
break
66+
// Check if height stabilized (changed less than 5%)
67+
let heightChange = abs(currentSize.height - lastHeight)
68+
let changePercent = heightChange / max(currentSize.height, 1) * 100
69+
70+
if changePercent < 5 {
71+
logger.info("Layout stabilized at cycle \(cycle): height=\(currentSize.height), change=\(changePercent)%")
72+
// One more cycle to be sure
73+
hostingView.layout()
74+
hostingView.layoutSubtreeIfNeeded()
75+
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.15))
76+
break
77+
}
5678
}
79+
lastHeight = currentSize.height
5780
}
5881

5982
// Calculate actual needed height after layout
6083
let fittingSize = hostingView.fittingSize
61-
let finalHeight = max(min(fittingSize.height, 2000), 400) // Cap between 400-2000
62-
hostingView.frame = CGRect(x: 0, y: 0, width: width + 40, height: finalHeight)
84+
logger.info("Final fittingSize after layout: \(fittingSize.width)×\(fittingSize.height)")
85+
86+
// INCREASED: Allow up to 4000px height for very complex diagrams
87+
// Old cap: 2000px → clipped large diagrams
88+
// New cap: 4000px → accommodate complex flowcharts
89+
let finalHeight = max(min(fittingSize.height, 4000), 400)
90+
let finalWidth = max(fittingSize.width, initialWidth)
91+
92+
hostingView.frame = CGRect(x: 0, y: 0, width: finalWidth, height: finalHeight)
93+
logger.info("Final frame before render: \(finalWidth)×\(finalHeight)")
6394

6495
// Final layout pass
6596
hostingView.layout()
@@ -68,14 +99,19 @@ struct MermaidImageRenderer {
6899

69100
// Render to bitmap
70101
guard let bitmapRep = hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds) else {
102+
logger.error("Failed to create bitmap representation")
71103
return nil
72104
}
105+
106+
logger.debug("Bitmap representation created: \(bitmapRep.pixelsWide)×\(bitmapRep.pixelsHigh)")
73107

74108
hostingView.cacheDisplay(in: hostingView.bounds, to: bitmapRep)
75109

76110
// Create NSImage from bitmap
77111
let image = NSImage(size: hostingView.bounds.size)
78112
image.addRepresentation(bitmapRep)
113+
114+
logger.info("Successfully rendered image: \(image.size.width)×\(image.size.height)")
79115

80116
return image
81117
}

Sources/UserInterface/Documents/MessageExportService.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,15 @@ public class MessageExportService {
205205
case .codeBlock(let language, let code):
206206
// Special handling for Mermaid diagrams - render as images
207207
if let lang = language, lang.lowercased() == "mermaid" {
208-
if let diagramImage = MermaidImageRenderer.renderDiagram(code: code, width: 550) {
208+
// INCREASED: Use 800px width for PDF export (was 550px)
209+
// Complex diagrams need more width for proper layout
210+
if let diagramImage = MermaidImageRenderer.renderDiagram(code: code, width: 800) {
209211
// Create attachment for image
210212
let imageAttachment = NSTextAttachment()
211213
imageAttachment.image = diagramImage
212214

213215
// Scale image to fit page width if needed
214-
let maxWidth: CGFloat = 550
216+
let maxWidth: CGFloat = 800
215217
let imageSize = diagramImage.size
216218
if imageSize.width > maxWidth {
217219
let scale = maxWidth / imageSize.width

0 commit comments

Comments
 (0)