diff --git a/apple/TablePro/Models/ERDiagram/ERDiagramDOTWriter.swift b/apple/TablePro/Models/ERDiagram/ERDiagramDOTWriter.swift new file mode 100644 index 0000000000..3dd230646c --- /dev/null +++ b/apple/TablePro/Models/ERDiagram/ERDiagramDOTWriter.swift @@ -0,0 +1,134 @@ +import Foundation + +enum ERDiagramDOTWriter { + private static let secondaryColor = "#6E6E73" + private static let headerColor = "#EFEFF4" + + static func document(for graph: ERDiagramGraph) -> String { + let nodes = ERDiagramExportOrder.nodes(of: graph) + var ports: [String: [String: String]] = [:] + for node in nodes { + let all = portIds(for: node.columns.map(\.name)) + ports[node.tableName] = all.filter { name, _ in node.displayColumns.contains { $0.name == name } } + } + + var lines = [ + "digraph \"ER Diagram\" {", + " graph [rankdir=LR, nodesep=0.5, ranksep=1.2];", + " node [shape=plain, fontname=\"Helvetica\", fontsize=11];", + " edge [dir=both, color=\"\(secondaryColor)\", arrowsize=0.8];", + ] + + for node in nodes { + lines.append("") + lines.append(" \(quoted(node.tableName)) [label=<") + lines.append(" ") + lines.append( + " " + ) + for column in node.displayColumns { + lines.append(" \(cell(for: column, port: ports[node.tableName]?[column.name]))") + } + lines.append("
\(html(node.tableName))
") + lines.append(" >];") + } + + let edges = ERDiagramExportOrder.edges(of: graph) + if !edges.isEmpty { + lines.append("") + } + for edge in edges { + let tail = endpoint(edge.fromTable, column: edge.fromColumn, ports: ports) + let head = endpoint(edge.toTable, column: edge.toColumn, ports: ports) + let (tailArrow, headArrow) = arrows(edge.cardinality) + lines.append(" \(tail) -> \(head) [arrowtail=\(tailArrow), arrowhead=\(headArrow)];") + } + + lines.append("}") + return lines.joined(separator: "\n") + "\n" + } + + // Graphviz puts the first shape against the table, as the canvas puts the crow or outer bar. + static func arrows(_ cardinality: ERCardinality) -> (tail: String, head: String) { + switch cardinality { + case .oneToOne: return ("teetee", "tee") + case .zeroOrOneToOne: return ("teeodot", "tee") + case .manyToOne: return ("crowtee", "tee") + case .zeroOrManyToOne: return ("crowodot", "tee") + case .manyToMany: return ("crow", "crow") + } + } + + // One cell per row keeps the port as wide as the table, so an edge stops at the border. + private static func cell(for column: ERColumnDisplay, port: String?) -> String { + let port = port.map { " PORT=\"\($0)\"" } ?? "" + var text = html(column.name) + if !column.dataType.isEmpty { + text += " \(html(column.dataType))" + } + let keys = [column.isPrimaryKey ? "PK" : nil, column.isForeignKey ? "FK" : nil].compactMap { $0 } + if !keys.isEmpty { + text += " \(keys.joined(separator: ", "))" + } + return "\(text)" + } + + // A column the view hides, or a many-to-many edge, has no port: the edge meets the table. + private static func endpoint(_ table: String, column: String, ports: [String: [String: String]]) -> String { + guard let port = ports[table]?[column] else { return quoted(table) } + return "\(quoted(table)):\(quoted(port))" + } + + // Graphviz splits a port reference at `:` into port and compass point, even quoted, and keeps an + // escaped backslash doubled, so a port is the name cut to `[A-Za-z0-9_]`, unique per table. + static func portIds(for columns: [String]) -> [String: String] { + var ids: [String: String] = [:] + var taken: Set = [] + for name in columns where ids[name] == nil { + let base = name.unicodeScalars.reduce(into: "") { result, scalar in + let isWord = scalar.isASCII && (scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) || scalar == "_") + if isWord { + result.unicodeScalars.append(scalar) + } else if !result.hasSuffix("_") { + result.append("_") + } + } + var candidate = base.isEmpty ? "_" : base + var suffix = 2 + while taken.contains(candidate) { + candidate = "\(base)_\(suffix)" + suffix += 1 + } + taken.insert(candidate) + ids[name] = candidate + } + return ids + } + + static func quoted(_ id: String) -> String { + let escaped = id + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + // Graphviz expands `\N` and `\G` even in an HTML label, so a backslash is doubled. + static func html(_ text: String) -> String { + var result = "" + for scalar in text.unicodeScalars { + switch scalar { + case "\\": result += "\\\\" + case "&": result += "&" + case "<": result += "<" + case ">": result += ">" + case "\"": result += """ + case "\t", "\n", "\r": result += " " + default: + let value = scalar.value + let allowed = (0x20...0xD7FF).contains(value) || (0xE000...0xFFFD).contains(value) || value >= 0x10000 + result.unicodeScalars.append(allowed ? scalar : "\u{FFFD}") + } + } + return result + } +} diff --git a/apple/TablePro/Models/ERDiagram/ERDiagramMermaidWriter.swift b/apple/TablePro/Models/ERDiagram/ERDiagramMermaidWriter.swift new file mode 100644 index 0000000000..ce18e591ea --- /dev/null +++ b/apple/TablePro/Models/ERDiagram/ERDiagramMermaidWriter.swift @@ -0,0 +1,156 @@ +import Foundation + +// Names are fitted to the lexer of Mermaid 11.6 and later, which GitHub renders with. +enum ERDiagramMermaidWriter { + static func document(for graph: ERDiagramGraph) -> String { + let nodes = ERDiagramExportOrder.nodes(of: graph) + let names = entityNames(for: nodes.map(\.tableName)) + var lines = ["erDiagram"] + + for node in nodes { + let name = names[node.tableName] ?? entityName(node.tableName) + guard !node.displayColumns.isEmpty else { + lines.append(" \(name)") + continue + } + lines.append(" \(name) {") + for column in node.displayColumns { + var line = " \(attributeWord(column.dataType)) \(attributeWord(column.name))" + let keys = [column.isPrimaryKey ? "PK" : nil, column.isForeignKey ? "FK" : nil].compactMap { $0 } + if !keys.isEmpty { + line += " " + keys.joined(separator: ", ") + } + lines.append(line) + } + lines.append(" }") + } + + for edge in ERDiagramExportOrder.edges(of: graph) { + let role = edge.cardinality == .manyToMany ? edge.fkName : edge.fromColumn + lines.append( + " \(names[edge.fromTable] ?? entityName(edge.fromTable)) \(relationship(edge.cardinality))" + + " \(names[edge.toTable] ?? entityName(edge.toTable))" + + " : \(quoted(role))" + ) + } + + return lines.joined(separator: "\n") + "\n" + } + + // Fenced, so a paste into GitHub or any Markdown that renders Mermaid draws the diagram. + static func markdownBlock(for graph: ERDiagramGraph) -> String { + "```mermaid\n" + document(for: graph) + "```\n" + } + + // Each end carries the marker the canvas draws there; the foreign key's table is on the left. + static func relationship(_ cardinality: ERCardinality) -> String { + switch cardinality { + case .oneToOne: return "||--||" + case .zeroOrOneToOne: return "|o--||" + case .manyToOne: return "}|--||" + case .zeroOrManyToOne: return "}o--||" + case .manyToMany: return "}o--o{" + } + } + + // Two tables fitted to one name would merge into one entity, so the later one takes a suffix. + static func entityNames(for tables: [String]) -> [String: String] { + var names: [String: String] = [:] + var taken: Set = [] + for table in tables.sorted() { + let base = fitted(table) + var candidate = base + var suffix = 2 + while taken.contains(candidate) { + candidate = "\(base)_\(suffix)" + suffix += 1 + } + taken.insert(candidate) + names[table] = "\"\(candidate)\"" + } + return names + } + + // Quoted, so a keyword (`end`, `one`, `to`) or a space stays a name. + static func entityName(_ name: String) -> String { + quoted(name) + } + + private static func quoted(_ text: String) -> String { + "\"\(fitted(text))\"" + } + + // The lexer reads `direction LR` anywhere on a line as a directive, quoted or not. + private static func fitted(_ text: String) -> String { + let forbidden: Set = ["\"", "%", "\\", "\r", "\n", "\u{0B}", "\u{08}", "\r\n"] + let cleaned = text.isEmpty ? "_" : String(text.map { forbidden.contains($0) ? "_" : $0 }) + return cleaned.replacingOccurrences( + of: "(direction)\\s+(TB|BT|RL|LR)", + with: "$1_$2", + options: [.regularExpression, .caseInsensitive] + ) + } + + // The lexer takes `pk` or `FK-1` for a key, so such a word gets a leading `_`. + static func attributeWord(_ text: String) -> String { + var word = "" + var replacing = false + for scalar in text.unicodeScalars { + if isWordScalar(scalar) { + word.unicodeScalars.append(scalar) + replacing = false + } else if !replacing { + word.append("_") + replacing = true + } + } + guard let first = word.unicodeScalars.first else { return "_" } + if !isLeadingScalar(first) || startsWithKey(word) { + word = "_" + word + } + return word + } + + // The lexer reads UTF-16, so a character past U+FFFF is two units it accepts. + private static func isLeadingScalar(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 0x41...0x5A, 0x61...0x7A, 0x5F: return true + default: return scalar.value >= 0xC0 + } + } + + private static func isWordScalar(_ scalar: Unicode.Scalar) -> Bool { + if isLeadingScalar(scalar) { return true } + switch scalar { + case "0"..."9", "-", "[", "]", "(", ")": return true + default: return false + } + } + + private static func startsWithKey(_ word: String) -> Bool { + let lowered = word.lowercased() + guard ["pk", "fk", "uk"].contains(where: { lowered.hasPrefix($0) }) else { return false } + guard let next = word.unicodeScalars.dropFirst(2).first else { return true } + switch next { + case "a"..."z", "A"..."Z", "0"..."9", "_": return false + default: return true + } + } +} + +// Ordered by name only, so an unchanged schema always writes the same file. +enum ERDiagramExportOrder { + static func nodes(of graph: ERDiagramGraph) -> [ERTableNode] { + graph.nodes.sorted { $0.tableName < $1.tableName } + } + + static func edges(of graph: ERDiagramGraph) -> [EREdge] { + let tables = Set(graph.nodes.map(\.tableName)) + return graph.edges + .filter { tables.contains($0.fromTable) && tables.contains($0.toTable) } + .sorted { lhs, rhs in + (lhs.fromTable, lhs.toTable, lhs.fromColumn, lhs.toColumn, lhs.fkName) + < (rhs.fromTable, rhs.toTable, rhs.fromColumn, rhs.toColumn, rhs.fkName) + } + } +} diff --git a/apple/TablePro/Models/ERDiagram/ERDiagramModels.swift b/apple/TablePro/Models/ERDiagram/ERDiagramModels.swift index 6d7dd4b49d..93eec473ef 100644 --- a/apple/TablePro/Models/ERDiagram/ERDiagramModels.swift +++ b/apple/TablePro/Models/ERDiagram/ERDiagramModels.swift @@ -145,8 +145,9 @@ enum ERDiagramGraphBuilder { var edges: [EREdge] = [] var seenFKNames: Set = [] - for (tableName, fks) in allForeignKeys { - for fk in fks { + // Sorted, because a dictionary's order changes per launch and decides port order on ties. + for tableName in allForeignKeys.keys.sorted() { + for fk in allForeignKeys[tableName] ?? [] { let edgeKey = "\(tableName).\(fk.name).\(fk.column)" guard !seenFKNames.contains(edgeKey) else { continue } seenFKNames.insert(edgeKey) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index d1d10ab57d..53d8175d37 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -36817,6 +36817,9 @@ } } } + }, + "Choose a location to save the diagram as %@." : { + }, "Choose a location to save the diagram as PNG." : { "localizations" : { @@ -49525,6 +49528,9 @@ }, "Copy as Markdown" : { + }, + "Copy as Mermaid" : { + }, "Copy as UPDATE Statements" : { @@ -183274,6 +183280,9 @@ } } } + }, + "The diagram could not be rendered as %@." : { + }, "The disconnect was cancelled." : { "localizations" : { diff --git a/apple/TablePro/Views/Components/DiagramExporter.swift b/apple/TablePro/Views/Components/DiagramExporter.swift new file mode 100644 index 0000000000..c7d201013a --- /dev/null +++ b/apple/TablePro/Views/Components/DiagramExporter.swift @@ -0,0 +1,64 @@ +import AppKit +import os +import SwiftUI +import UniformTypeIdentifiers + +// Save and Copy for the ER and query plan diagrams, so both export the same way. +enum DiagramExporter { + private static let logger = Logger(subsystem: "com.TablePro", category: "DiagramExporter") + static let renderScale: CGFloat = 2.0 + + @MainActor + static func image(of view: some View) -> NSImage? { + let renderer = ImageRenderer(content: view) + renderer.scale = renderScale + return renderer.nsImage + } + + @MainActor + static func export(_ view: some View, defaultFileName: String, title: String) { + save(pngData(image(of: view)), contentType: .png, formatName: "PNG", defaultFileName: defaultFileName, title: title) + } + + static func pngData(_ image: NSImage?) -> Data? { + guard let tiffData = image?.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) + else { return nil } + return bitmap.representation(using: .png, properties: [:]) + } + + // `data` is nil when the diagram could not be rendered in that format. + @MainActor + static func save(_ data: Data?, contentType: UTType, formatName: String, defaultFileName: String, title: String) { + guard let window = AlertHelper.resolveWindow(nil) else { return } + guard let data else { + logger.error("Failed to render the diagram as \(formatName, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Export Failed"), + message: String(format: String(localized: "The diagram could not be rendered as %@."), formatName), + window: window + ) + return + } + + let panel = NSSavePanel() + panel.allowedContentTypes = [contentType] + panel.nameFieldStringValue = defaultFileName + panel.title = title + panel.message = String(format: String(localized: "Choose a location to save the diagram as %@."), formatName) + + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + do { + try data.write(to: url, options: .atomic) + } catch { + logger.error("Failed to write the diagram: \(error.localizedDescription)") + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the diagram"), + message: error.localizedDescription, + window: window + ) + } + } + } +} diff --git a/apple/TablePro/Views/Components/DiagramImageExporter.swift b/apple/TablePro/Views/Components/DiagramImageExporter.swift deleted file mode 100644 index 130875a53c..0000000000 --- a/apple/TablePro/Views/Components/DiagramImageExporter.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// DiagramImageExporter.swift -// TablePro -// -// Renders a diagram to a PNG at its natural scale, for the standard Copy command and for -// Save. Shared by the ER diagram and the query plan diagram so both export identically. -// - -import AppKit -import os -import SwiftUI -import UniformTypeIdentifiers - -enum DiagramImageExporter { - private static let logger = Logger(subsystem: "com.TablePro", category: "DiagramImageExporter") - static let renderScale: CGFloat = 2.0 - - @MainActor - static func image(of view: some View) -> NSImage? { - let renderer = ImageRenderer(content: view) - renderer.scale = renderScale - return renderer.nsImage - } - - @MainActor - static func export(_ view: some View, defaultFileName: String, title: String) { - export(image(of: view), defaultFileName: defaultFileName, title: title) - } - - @MainActor - static func export(_ image: NSImage?, defaultFileName: String, title: String) { - guard let image else { - logger.error("Failed to render diagram to image") - presentFailure() - return - } - - let panel = NSSavePanel() - panel.allowedContentTypes = [.png] - panel.nameFieldStringValue = defaultFileName - panel.title = title - panel.message = String(localized: "Choose a location to save the diagram as PNG.") - - guard let window = AlertHelper.resolveWindow(nil) else { return } - panel.beginSheetModal(for: window) { response in - guard response == .OK, let url = panel.url else { return } - guard let tiffData = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let pngData = bitmap.representation(using: .png, properties: [:]) - else { - AlertHelper.showErrorSheet( - title: String(localized: "Could not export the diagram"), - message: String(localized: "The diagram could not be converted to a PNG image."), - window: window - ) - return - } - do { - try pngData.write(to: url) - } catch { - logger.error("Failed to write PNG: \(error.localizedDescription)") - AlertHelper.showErrorSheet( - title: String(localized: "Could not export the diagram"), - message: error.localizedDescription, - window: window - ) - } - } - } - - @MainActor - private static func presentFailure() { - let alert = NSAlert() - alert.messageText = String(localized: "Export Failed") - alert.informativeText = String(localized: "Failed to render the diagram image.") - alert.alertStyle = .warning - if let window = AlertHelper.resolveWindow(nil) { - alert.beginSheetModal(for: window) - } else { - alert.runModal() - } - } -} diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramCanvas.swift b/apple/TablePro/Views/ERDiagram/ERDiagramCanvas.swift new file mode 100644 index 0000000000..5baadc95fa --- /dev/null +++ b/apple/TablePro/Views/ERDiagram/ERDiagramCanvas.swift @@ -0,0 +1,69 @@ +import AppKit + +// The screen and every export draw through this, so no export drifts from the canvas. +@MainActor +protocol ERDiagramCanvas { + func fill(_ path: CGPath, color: CGColor) + // Round caps and joins for connectors, butt caps and miter joins for borders and rules. + func stroke(_ path: CGPath, color: CGColor, lineWidth: CGFloat, rounded: Bool) + func clip(to path: CGPath, _ draw: () -> Void) + func drawText( + _ text: String, + font: NSFont, + color: NSColor, + at point: CGPoint, + anchor: ERDiagramTextAnchor, + maxWidth: CGFloat + ) + func drawSymbol(named name: String, pointSize: CGFloat, color: NSColor, at point: CGPoint, anchor: ERDiagramTextAnchor) +} + +extension ERDiagramCanvas { + func drawText(_ text: String, font: NSFont, color: NSColor, at point: CGPoint, anchor: ERDiagramTextAnchor) { + drawText(text, font: font, color: color, at: point, anchor: anchor, maxWidth: .greatestFiniteMagnitude) + } +} + +// The context is expected to be flipped and to be `NSGraphicsContext.current`, which symbols draw into. +@MainActor +struct ERDiagramContextCanvas: ERDiagramCanvas { + let context: CGContext + + func fill(_ path: CGPath, color: CGColor) { + context.addPath(path) + context.setFillColor(color) + context.fillPath() + } + + func stroke(_ path: CGPath, color: CGColor, lineWidth: CGFloat, rounded: Bool) { + context.setStrokeColor(color) + context.setLineWidth(lineWidth) + context.setLineCap(rounded ? .round : .butt) + context.setLineJoin(rounded ? .round : .miter) + context.addPath(path) + context.strokePath() + } + + func clip(to path: CGPath, _ draw: () -> Void) { + context.saveGState() + context.addPath(path) + context.clip() + draw() + context.restoreGState() + } + + func drawText( + _ text: String, + font: NSFont, + color: NSColor, + at point: CGPoint, + anchor: ERDiagramTextAnchor, + maxWidth: CGFloat + ) { + ERDiagramTextRenderer.draw(text, font: font, color: color, at: point, anchor: anchor, maxWidth: maxWidth, in: context) + } + + func drawSymbol(named name: String, pointSize: CGFloat, color: NSColor, at point: CGPoint, anchor: ERDiagramTextAnchor) { + ERDiagramSymbolRenderer.draw(named: name, pointSize: pointSize, color: color, at: point, anchor: anchor) + } +} diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramDrawing.swift b/apple/TablePro/Views/ERDiagram/ERDiagramDrawing.swift index 8a7f862eaa..baef7e849c 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramDrawing.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramDrawing.swift @@ -1,11 +1,3 @@ -// -// ERDiagramDrawing.swift -// TablePro -// -// Text and symbol drawing for the ER diagram's CoreGraphics renderers, matching what the -// SwiftUI `GraphicsContext.draw(_:at:anchor:)` calls they replaced put on screen. -// - import AppKit enum ERDiagramTextAnchor { @@ -24,13 +16,9 @@ enum ERDiagramTextRenderer { private static let lineCacheLimit = 4_000 private static var lineCache: [LineKey: CTLine] = [:] + private static let ellipsis = "\u{2026}" - /// The anchor point is the text's visual centre on the vertical axis, which is where - /// `GraphicsContext.draw(_:at:anchor:)` put it. The context is flipped, so the baseline sits - /// below that centre by half the difference between ascent and descent. - /// - /// Text wider than `maxWidth` is cut at its end with an ellipsis, the way a truncating label is, - /// so two texts given widths that add up to their room can never draw over each other. + // The point is the text's visual centre, not its baseline. static func draw( _ text: String, font: NSFont, @@ -53,20 +41,49 @@ enum ERDiagramTextRenderer { context.saveGState() context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) - context.textPosition = CGPoint(x: originX, y: point.y + (font.ascender + font.descender) / 2) + context.textPosition = CGPoint(x: originX, y: baseline(for: point.y, font: font)) CTLineDraw(line, context) context.restoreGState() } - /// The width `draw` lays `text` out at before any truncation. + static func baseline(for centerY: CGFloat, font: NSFont) -> CGFloat { + centerY + (font.ascender + font.descender) / 2 + } + static func width(of text: String, font: NSFont) -> CGFloat { guard !text.isEmpty else { return 0 } return CGFloat(CTLineGetTypographicBounds(line(for: text, font: font, color: .labelColor), nil, nil, nil)) } + // What `draw` shows: the longest prefix that fits beside an ellipsis, as an end-truncated `CTLine` keeps. + static func fittedText(_ text: String, font: NSFont, maxWidth: CGFloat) -> String { + guard !text.isEmpty, maxWidth > 0 else { return "" } + guard measure(text, font: font) > maxWidth else { return text } + + let characters = Array(text) + var low = 0 + var high = characters.count - 1 + while low < high { + let mid = (low + high + 1) / 2 + if measure(String(characters[.. CGFloat { + let attributed = NSAttributedString(string: text, attributes: [.font: font]) + let line = CTLineCreateWithAttributedString(attributed as CFAttributedString) + return CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + } + private static func fitted(_ line: CTLine, to maxWidth: CGFloat, font: NSFont, color: NSColor) -> CTLine { guard CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) > maxWidth else { return line } - let ellipsis = self.line(for: "\u{2026}", font: font, color: color) + let ellipsis = self.line(for: ellipsis, font: font, color: color) return CTLineCreateTruncatedLine(line, Double(maxWidth), .end, ellipsis) ?? ellipsis } @@ -83,14 +100,7 @@ enum ERDiagramTextRenderer { } } -/// Configuring a symbol resolves its dynamic colour, so a cached image belongs to exactly one -/// appearance and contrast setting, the same rule `DataGridCellAccessoryGlyph` follows. -/// -/// What is cached is the configured `NSImage`, not a bitmap of it. A symbol is vector art and the -/// diagram is drawn at whatever scale the scroll view is magnified to, so rasterising once at 1x -/// and stretching that bitmap would leave every badge soft on a Retina display, in the 2x PNG -/// export, and worse again at 300%. `NSImage.draw(in:)` renders into the current context at its -/// resolution instead. +// A configured symbol bakes its colour, so the cache is keyed by appearance and contrast. @MainActor enum ERDiagramSymbolRenderer { private struct Key: Hashable { @@ -101,7 +111,7 @@ enum ERDiagramSymbolRenderer { let increasedContrast: Bool } - private struct Glyph { + struct Glyph { let image: NSImage let size: CGSize } @@ -116,24 +126,33 @@ enum ERDiagramSymbolRenderer { anchor: ERDiagramTextAnchor ) { guard let glyph = glyph(named: name, pointSize: pointSize, color: color) else { return } + // Every diagram context is flipped, and without `respectFlipped` a symbol draws upside down. + glyph.image.draw( + in: frame(of: glyph, at: point, anchor: anchor), + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: nil + ) + } + static func frame(of glyph: Glyph, at point: CGPoint, anchor: ERDiagramTextAnchor) -> CGRect { let originX: CGFloat switch anchor { case .leading: originX = point.x case .trailing: originX = point.x - glyph.size.width case .center: originX = point.x - glyph.size.width / 2 } - let rect = CGRect( + return CGRect( x: originX, y: point.y - glyph.size.height / 2, width: glyph.size.width, height: glyph.size.height ) - - glyph.image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) } - private static func glyph(named name: String, pointSize: CGFloat, color: NSColor) -> Glyph? { + static func glyph(named name: String, pointSize: CGFloat, color: NSColor) -> Glyph? { let key = Key( name: name, pointSize: pointSize, diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift b/apple/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift index c5f2a6dd97..4bafe3c107 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift @@ -1,6 +1,5 @@ import AppKit -/// Renders FK edges with crow's foot notation with CoreGraphics into the current drawing context. @MainActor enum ERDiagramEdgeRenderer { private struct ResolvedEdge { @@ -18,7 +17,7 @@ enum ERDiagramEdgeRenderer { nodeRects: [UUID: CGRect], nodeIndex: [String: UUID], strokeColor: CGColor, - in context: CGContext + on canvas: some ERDiagramCanvas ) { let resolved: [ResolvedEdge] = edges.compactMap { edge -> ResolvedEdge? in guard let fromId = nodeIndex[edge.fromTable], @@ -29,9 +28,7 @@ enum ERDiagramEdgeRenderer { return ResolvedEdge(edge: edge, fromId: fromId, toId: toId, fromRect: fromRect, toRect: toRect) } - // A self edge gets its own loop and never a border port, so it is kept out of the counts - // and the pools: letting it take a spread slot would push a real edge off centre for a - // port that is never used. + // A self edge never takes a border port, so it stays out of the port counts. let pairs = resolved.filter { $0.fromId != $0.toId } var selfLoopIndex: [String: Int] = [:] var selfLoopCounts: [UUID: Int] = [:] @@ -74,12 +71,6 @@ enum ERDiagramEdgeRenderer { } } - context.saveGState() - context.setStrokeColor(strokeColor) - context.setLineWidth(strokeWidth) - context.setLineCap(.round) - context.setLineJoin(.round) - for item in resolved { let key = edgeKey(item) let path: CGPath @@ -111,13 +102,14 @@ enum ERDiagramEdgeRenderer { path = curve } - context.addPath(path) - context.strokePath() - drawSourceMarker(cardinality: item.edge.cardinality, at: srcPort, toward: cp1, in: context) - drawDestinationMarker(cardinality: item.edge.cardinality, at: dstPort, toward: cp2, in: context) + // Each part is its own stroke, so a translucent edge darkens where a marker crosses it. + let parts = [path] + + sourceMarker(cardinality: item.edge.cardinality, at: srcPort, toward: cp1) + + destinationMarker(cardinality: item.edge.cardinality, at: dstPort, toward: cp2) + for part in parts { + canvas.stroke(part, color: strokeColor, lineWidth: strokeWidth, rounded: true) + } } - - context.restoreGState() } private static func edgeKey(_ item: ResolvedEdge) -> String { @@ -140,21 +132,7 @@ enum ERDiagramEdgeRenderer { } } - /// A relationship a table has with itself, `employees.manager_id -> employees.id`, drawn as a - /// loop out of one side rather than as an edge between two nodes. - /// - /// `computePorts` cannot serve it. With one rect for both ends the vertical gap is negative, so - /// it takes the side-port branch, and the equality makes it choose the left border for the - /// source and the right border for the destination: a curve straight through the table, drawn - /// before the node's own opaque fill and therefore erased by it. Measured on a 220x80 node, 4 - /// of 444 curve pixels survived, and both cardinality markers pointed into the body. - /// - /// Both ports go on the trailing edge and the control points push out past it, which is what - /// Graphviz does for a self edge (TSE93 5.1.3, "loops on the sides of nodes"). The horizontal - /// distance along the curve is `3 * bow * t * (1 - t)`, never negative, so the loop provably - /// never re-enters the node whatever its size; the bulge peaks at three quarters of `bow`. - /// The bow is capped at the layout's own column gap, unscaled, because that gap is what the - /// packer leaves to the right of a node and it does not grow with the text size. + // Out of the trailing edge and back (TSE93 5.1.3); the reach `3 * bow * t * (1 - t)` never re-enters the node. static func selfLoop(in rect: CGRect, index: Int) -> SelfLoop { let scale = ERDiagramLayout.typeScale let spread = min( @@ -174,14 +152,10 @@ enum ERDiagramEdgeRenderer { ) } - /// A ceiling on how far a nested loop can reach, so the 80pt of canvas padding the view model - /// adds is always enough to scroll to one: the bulge is three quarters of the bow and the - /// markers reach further still. Loops past the second on a single table coincide, which is the - /// better trade for a schema that has three. + // Keeps a nested loop inside the 80 pt of canvas padding the view model adds. private static let maximumBow: CGFloat = 84 - /// Every point the loop and its two markers touch, which is more than the node rect: the export - /// crops to the drawn content, and cropping to the tables alone cuts the apex off. + // The export crops to what is drawn, and the loop with its markers reaches past the node rect. static func selfLoopBounds(in rect: CGRect, index: Int) -> CGRect { let loop = selfLoop(in: rect, index: index) let apex = rect.maxX + (loop.sourceControl.x - rect.maxX) * 0.75 @@ -194,85 +168,60 @@ enum ERDiagramEdgeRenderer { .insetBy(dx: -markerReach, dy: -markerReach) } - /// The crow's foot reaches 12pt along the tangent and spreads 8pt across it, and the stroke - /// adds its own half width. + // The crow's foot reaches 12 pt along the tangent and 8 pt across it, plus half the stroke. private static let markerReach: CGFloat = 14 // MARK: - Cardinality Markers - private static func drawSourceMarker( - cardinality: ERCardinality, - at point: CGPoint, - toward target: CGPoint, - in context: CGContext - ) { + private static func sourceMarker(cardinality: ERCardinality, at point: CGPoint, toward target: CGPoint) -> [CGPath] { switch cardinality { case .oneToOne: - drawCompoundEndMarker(at: point, toward: target, isMany: false, isMandatory: true, in: context) + return compoundEndMarker(at: point, toward: target, isMany: false, isMandatory: true) case .zeroOrOneToOne: - drawCompoundEndMarker(at: point, toward: target, isMany: false, isMandatory: false, in: context) + return compoundEndMarker(at: point, toward: target, isMany: false, isMandatory: false) case .manyToOne: - drawCompoundEndMarker(at: point, toward: target, isMany: true, isMandatory: true, in: context) + return compoundEndMarker(at: point, toward: target, isMany: true, isMandatory: true) case .zeroOrManyToOne: - drawCompoundEndMarker(at: point, toward: target, isMany: true, isMandatory: false, in: context) + return compoundEndMarker(at: point, toward: target, isMany: true, isMandatory: false) case .manyToMany: - drawCrowFoot(at: point, toward: target, in: context) - default: - drawCompoundEndMarker(at: point, toward: target, isMany: true, isMandatory: true, in: context) + return [crowFoot(at: point, toward: target)] } } - private static func drawDestinationMarker( - cardinality: ERCardinality, - at point: CGPoint, - toward target: CGPoint, - in context: CGContext - ) { + private static func destinationMarker(cardinality: ERCardinality, at point: CGPoint, toward target: CGPoint) -> [CGPath] { switch cardinality { case .manyToMany: - drawCrowFoot(at: point, toward: target, in: context) + return [crowFoot(at: point, toward: target)] default: - drawOneBar(at: point, toward: target, in: context) + return [oneBar(at: point, toward: target)] } } - private static func drawCompoundEndMarker( + private static func compoundEndMarker( at point: CGPoint, toward target: CGPoint, isMany: Bool, - isMandatory: Bool, - in context: CGContext - ) { - if isMany { - drawCrowFoot(at: point, toward: target, in: context) - } else { - drawOneBar(at: point, toward: target, in: context) - } + isMandatory: Bool + ) -> [CGPath] { + let outer = isMany ? crowFoot(at: point, toward: target) : oneBar(at: point, toward: target) let angle = atan2(target.y - point.y, target.x - point.x) let innerOffset: CGFloat = 14 let innerPoint = CGPoint(x: point.x + innerOffset * cos(angle), y: point.y + innerOffset * sin(angle)) + let inner = isMandatory ? oneBar(at: innerPoint, toward: target) : circle(at: innerPoint) - if isMandatory { - drawOneBar(at: innerPoint, toward: target, in: context) - } else { - drawCircle(at: innerPoint, in: context) - } + return [outer, inner] } - private static func drawCircle(at point: CGPoint, in context: CGContext) { + private static func circle(at point: CGPoint) -> CGPath { let radius: CGFloat = 3.5 let rect = CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2) - context.addEllipse(in: rect) - context.strokePath() + return CGPath(ellipseIn: rect, transform: nil) } // MARK: - Port Selection - /// Top-to-bottom Sugiyama layout: edges exit from bottom, enter from top. - /// Multiple edges on the same table are spaced evenly along the edge. - /// Returns (srcPort, dstPort, verticalPorts). - /// Uses actual port-to-port gap to decide routing direction. + // Vertical ports only when the gap between the closest edges leaves room for a clean curve. static func computePorts( from fromRect: CGRect, to toRect: CGRect, srcIdx: Int, srcTotal: Int, @@ -281,7 +230,6 @@ enum ERDiagramEdgeRenderer { let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) - // Measure the actual gap between the closest edges (not centers) let verticalGap: CGFloat if fromCenter.y < toCenter.y { verticalGap = toRect.minY - fromRect.maxY @@ -289,8 +237,6 @@ enum ERDiagramEdgeRenderer { verticalGap = fromRect.minY - toRect.maxY } - // Use vertical (bottom→top) ports only when there's enough gap for clean routing. - // When tables overlap vertically or are too close, use side ports. let minGapForVertical: CGFloat = 30 if verticalGap > minGapForVertical { @@ -312,7 +258,6 @@ enum ERDiagramEdgeRenderer { } } - /// Distributes N ports evenly along an edge, with padding from corners. private static func spreadOffset(in length: CGFloat, index: Int, total: Int, base: CGFloat) -> CGFloat { let padding: CGFloat = min(length * 0.2, 30) let usable = length - padding * 2 @@ -328,12 +273,10 @@ enum ERDiagramEdgeRenderer { let cp2: CGPoint if verticalPorts { - // Bottom→top ports: control points are directly below src / above dst let offset = max(abs(dst.y - src.y) * 0.4, 20) cp1 = CGPoint(x: src.x, y: src.y + (dst.y > src.y ? offset : -offset)) cp2 = CGPoint(x: dst.x, y: dst.y + (src.y > dst.y ? offset : -offset)) } else { - // Side ports: control points are horizontally offset from src/dst let offset = max(abs(dst.x - src.x) * 0.4, 20) cp1 = CGPoint(x: src.x + (dst.x > src.x ? offset : -offset), y: src.y) cp2 = CGPoint(x: dst.x + (src.x > dst.x ? offset : -offset), y: dst.y) @@ -347,7 +290,7 @@ enum ERDiagramEdgeRenderer { // MARK: - Crow's Foot (Many Side) - private static func drawCrowFoot(at point: CGPoint, toward target: CGPoint, in context: CGContext) { + private static func crowFoot(at point: CGPoint, toward target: CGPoint) -> CGPath { let length: CGFloat = 12 let spread: CGFloat = 8 let angle = atan2(target.y - point.y, target.x - point.x) @@ -355,22 +298,22 @@ enum ERDiagramEdgeRenderer { let tip = CGPoint(x: point.x + length * cos(angle), y: point.y + length * sin(angle)) let perpAngle = angle + .pi / 2 - // Three prongs from the tip back to spread points let top = CGPoint(x: point.x + spread * cos(perpAngle), y: point.y + spread * sin(perpAngle)) let bottom = CGPoint(x: point.x - spread * cos(perpAngle), y: point.y - spread * sin(perpAngle)) - context.move(to: tip) - context.addLine(to: top) - context.move(to: tip) - context.addLine(to: point) - context.move(to: tip) - context.addLine(to: bottom) - context.strokePath() + let path = CGMutablePath() + path.move(to: tip) + path.addLine(to: top) + path.move(to: tip) + path.addLine(to: point) + path.move(to: tip) + path.addLine(to: bottom) + return path } // MARK: - One Bar (PK Side) - private static func drawOneBar(at point: CGPoint, toward target: CGPoint, in context: CGContext) { + private static func oneBar(at point: CGPoint, toward target: CGPoint) -> CGPath { let barWidth: CGFloat = 10 let angle = atan2(target.y - point.y, target.x - point.x) let perpAngle = angle + .pi / 2 @@ -378,8 +321,9 @@ enum ERDiagramEdgeRenderer { let top = CGPoint(x: point.x + barWidth * cos(perpAngle), y: point.y + barWidth * sin(perpAngle)) let bottom = CGPoint(x: point.x - barWidth * cos(perpAngle), y: point.y - barWidth * sin(perpAngle)) - context.move(to: top) - context.addLine(to: bottom) - context.strokePath() + let path = CGMutablePath() + path.move(to: top) + path.addLine(to: bottom) + return path } } diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramExportFormat.swift b/apple/TablePro/Views/ERDiagram/ERDiagramExportFormat.swift new file mode 100644 index 0000000000..7c74601042 --- /dev/null +++ b/apple/TablePro/Views/ERDiagram/ERDiagramExportFormat.swift @@ -0,0 +1,42 @@ +import UniformTypeIdentifiers + +enum ERDiagramExportFormat: CaseIterable, Identifiable { + case png + case svg + case pdf + case mermaid + case graphviz + + var id: Self { self } + + var name: String { + switch self { + case .png: return "PNG" + case .svg: return "SVG" + case .pdf: return "PDF" + case .mermaid: return "Mermaid" + case .graphviz: return "Graphviz DOT" + } + } + + // Graphviz recommends `.gv`: `.dot` resolves to Word's template type. + var fileExtension: String { + switch self { + case .png: return "png" + case .svg: return "svg" + case .pdf: return "pdf" + case .mermaid: return "mmd" + case .graphviz: return "gv" + } + } + + var contentType: UTType { + switch self { + case .png: return .png + case .svg: return .svg + case .pdf: return .pdf + case .mermaid, .graphviz: + return UTType(filenameExtension: fileExtension, conformingTo: .plainText) ?? .plainText + } + } +} diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift b/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift index fc1ef84758..79654dafb9 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift @@ -1,12 +1,6 @@ import AppKit -/// Renders table nodes with CoreGraphics into the current flipped drawing context. -/// -/// This used to draw into a SwiftUI `Canvas`. A `Canvas` cannot be the drawing surface of a -/// magnifying `NSScrollView`: below 50% magnification SwiftUI truncates the Canvas's own drawing -/// region to `contentSize * magnification + 128` document points and paints nothing past it, which -/// is what left the diagram half painted at its fit-to-window zoom (#2692). AppKit draws a plain -/// view at every scale, so the diagram owns its pixels the way the data grid owns its cells. +// AppKit draws a plain view at every magnification, which a SwiftUI `Canvas` did not (#2692). @MainActor enum ERDiagramNodeRenderer { private static var headerTextXOffset: CGFloat { 28 * ERDiagramLayout.typeScale } @@ -15,7 +9,7 @@ enum ERDiagramNodeRenderer { private static var columnNameXOffset: CGFloat { 24 * ERDiagramLayout.typeScale } private static var typeRightMargin: CGFloat { 8 * ERDiagramLayout.typeScale } private static var nameTypeGap: CGFloat { 8 * ERDiagramLayout.typeScale } - /// How much of a type stays readable beside a long name: enough for "varchar" or "timestam". + // Enough of a type stays readable beside a long name: "varchar" or "timestam". private static let typeFloorCharacters = 8 private static let maxTableNameChars = 24 private static let cornerRadius: CGFloat = 6 @@ -46,50 +40,48 @@ enum ERDiagramNodeRenderer { isSelected: Bool, clusterColor: NSColor?, palette: ERDiagramPalette, - in context: CGContext + on canvas: some ERDiagramCanvas ) { let scale = ERDiagramLayout.typeScale let body = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) - context.addPath(body) - context.setFillColor(palette.nodeFill) - context.fillPath() + canvas.fill(body, color: palette.nodeFill) let headerHeight = ERDiagramLayout.headerHeight - let headerRect = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: headerHeight) - - context.saveGState() - context.addPath(body) - context.clip() - if let headerFill = palette.headerFill { - context.setFillColor(headerFill) - context.fill(headerRect) - } - // A cluster's hue groups related tables, so it still washes a themed header. - if palette.headerFill == nil || clusterColor != nil { - context.setFillColor(palette.headerWash(cluster: clusterColor)) - context.fill(headerRect) + let headerRect = CGPath( + rect: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: headerHeight), + transform: nil + ) + + canvas.clip(to: body) { + if let headerFill = palette.headerFill { + canvas.fill(headerRect, color: headerFill) + } + // A cluster's hue groups related tables, so it still washes a themed header. + if palette.headerFill == nil || clusterColor != nil { + canvas.fill(headerRect, color: palette.headerWash(cluster: clusterColor)) + } } - context.restoreGState() - context.addPath(body) - context.setStrokeColor(isSelected ? palette.selectionRule : palette.rule) - context.setLineWidth(isSelected ? 2 : 1) - context.strokePath() + canvas.stroke( + body, + color: isSelected ? palette.selectionRule : palette.rule, + lineWidth: isSelected ? 2 : 1, + rounded: false + ) let displayName = (node.tableName as NSString).length > maxTableNameChars ? String(node.tableName.prefix(maxTableNameChars)) + "\u{2026}" : node.tableName - ERDiagramTextRenderer.draw( + canvas.drawText( displayName, font: .monospacedSystemFont(ofSize: headerPointSize * scale, weight: .semibold), color: palette.headerText, at: CGPoint(x: rect.minX + headerTextXOffset, y: rect.minY + headerHeight / 2), - anchor: .leading, - in: context + anchor: .leading ) - ERDiagramSymbolRenderer.draw( + canvas.drawSymbol( named: node.isJunctionTable ? "arrow.left.arrow.right" : "tablecells", pointSize: iconPointSize * scale, color: palette.secondaryText, @@ -98,17 +90,14 @@ enum ERDiagramNodeRenderer { ) let dividerY = rect.minY + headerHeight - context.setStrokeColor(palette.rule) - context.setLineWidth(0.5) - context.move(to: CGPoint(x: rect.minX, y: dividerY)) - context.addLine(to: CGPoint(x: rect.maxX, y: dividerY)) - context.strokePath() - - context.saveGState() - context.addPath(body) - context.clip() - drawColumns(node: node, rect: rect, dividerY: dividerY, scale: scale, palette: palette, in: context) - context.restoreGState() + let divider = CGMutablePath() + divider.move(to: CGPoint(x: rect.minX, y: dividerY)) + divider.addLine(to: CGPoint(x: rect.maxX, y: dividerY)) + canvas.stroke(divider, color: palette.rule, lineWidth: 0.5, rounded: false) + + canvas.clip(to: body) { + drawColumns(node: node, rect: rect, dividerY: dividerY, scale: scale, palette: palette, on: canvas) + } } private static func drawColumns( @@ -117,7 +106,7 @@ enum ERDiagramNodeRenderer { dividerY: CGFloat, scale: CGFloat, palette: ERDiagramPalette, - in context: CGContext + on canvas: some ERDiagramCanvas ) { let rowHeight = ERDiagramLayout.columnRowHeight let nameFont = columnNameFont(scale: scale) @@ -127,7 +116,7 @@ enum ERDiagramNodeRenderer { let rowY = dividerY + CGFloat(index) * rowHeight + rowHeight / 2 if column.isPrimaryKey { - ERDiagramSymbolRenderer.draw( + canvas.drawSymbol( named: "key.fill", pointSize: badgePointSize * scale, color: .systemYellow, @@ -135,7 +124,7 @@ enum ERDiagramNodeRenderer { anchor: .center ) } else if column.isForeignKey { - ERDiagramSymbolRenderer.draw( + canvas.drawSymbol( named: "link", pointSize: badgePointSize * scale, color: .systemBlue, @@ -145,24 +134,22 @@ enum ERDiagramNodeRenderer { } let widths = columnTextWidths(name: column.name, type: column.dataType, nodeWidth: rect.width) - ERDiagramTextRenderer.draw( + canvas.drawText( column.name, font: nameFont, color: palette.text, at: CGPoint(x: rect.minX + columnNameXOffset, y: rowY), anchor: .leading, - maxWidth: widths.name, - in: context + maxWidth: widths.name ) - ERDiagramTextRenderer.draw( + canvas.drawText( column.dataType, font: typeFont, color: palette.secondaryText, at: CGPoint(x: rect.maxX - typeRightMargin, y: rowY), anchor: .trailing, - maxWidth: widths.type, - in: context + maxWidth: widths.type ) } } @@ -175,12 +162,7 @@ enum ERDiagramNodeRenderer { .monospacedSystemFont(ofSize: columnTypePointSize * scale, weight: .regular) } - /// The widths a column row draws its name and type at, measured in the fonts the row uses. - /// - /// The name is drawn from the leading edge and the type to the trailing edge, so nothing kept - /// the two apart: a long name ran under its type ("shipping_addr" over "character varying(…"). - /// They now share the row. The name comes first, because it is what a reader is looking for, - /// but it leaves the type at least its first few characters; the type then takes what is left. + // The name comes first but leaves the type its first few characters. static func columnTextWidths(name: String, type: String, nodeWidth: CGFloat) -> ERColumnTextWidths { let scale = ERDiagramLayout.typeScale let typeFont = columnTypeFont(scale: scale) @@ -197,9 +179,7 @@ enum ERDiagramNodeRenderer { } } -/// How one column row shares its room between the name and the type. Each width is the most that -/// text may draw at; neither ever exceeds the text's own width, and the two plus the gap between -/// them never exceed the room. +// Neither width exceeds its text's own width, and the two plus the gap never exceed the room. struct ERColumnTextWidths: Equatable { let name: CGFloat let type: CGFloat diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramSVGCanvas.swift b/apple/TablePro/Views/ERDiagram/ERDiagramSVGCanvas.swift new file mode 100644 index 0000000000..883a16a881 --- /dev/null +++ b/apple/TablePro/Views/ERDiagram/ERDiagramSVGCanvas.swift @@ -0,0 +1,245 @@ +import AppKit + +// A symbol has no public vector outline, so each one is a PNG in ``. +@MainActor +final class ERDiagramSVGCanvas: ERDiagramCanvas { + private struct SymbolKey: Hashable { + let name: String + let pointSize: CGFloat + let color: NSColor + } + + private static let symbolScale: CGFloat = 4 + private static let monospaceFamily = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace" + private static let proportionalFamily = "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif" + + private var defs: [String] = [] + private var body: [String] = [] + private var depth = 0 + private var clipIds: [String: String] = [:] + private var symbolIds: [SymbolKey: String] = [:] + + func fill(_ path: CGPath, color: CGColor) { + append("") + } + + func stroke(_ path: CGPath, color: CGColor, lineWidth: CGFloat, rounded: Bool) { + var attributes = " fill=\"none\"\(Self.paint("stroke", color)) stroke-width=\"\(Self.number(lineWidth))\"" + if rounded { + attributes += " stroke-linecap=\"round\" stroke-linejoin=\"round\"" + } + append("") + } + + func clip(to path: CGPath, _ draw: () -> Void) { + let data = Self.pathData(path) + let id: String + if let existing = clipIds[data] { + id = existing + } else { + id = "clip\(clipIds.count + 1)" + clipIds[data] = id + defs.append("") + } + group("", draw) + } + + func translated(by offset: CGPoint, _ draw: () -> Void) { + group("", draw) + } + + func drawText( + _ text: String, + font: NSFont, + color: NSColor, + at point: CGPoint, + anchor: ERDiagramTextAnchor, + maxWidth: CGFloat + ) { + let shown = ERDiagramTextRenderer.fittedText(text, font: font, maxWidth: maxWidth) + guard !shown.isEmpty else { return } + + var attributes = " x=\"\(Self.number(point.x))\"" + attributes += " y=\"\(Self.number(ERDiagramTextRenderer.baseline(for: point.y, font: font)))\"" + if !font.isFixedPitch { + attributes += " font-family=\"\(Self.proportionalFamily)\"" + } + attributes += " font-size=\"\(Self.number(font.pointSize))\"" + let weight = Self.cssWeight(of: font) + if weight != 400 { + attributes += " font-weight=\"\(weight)\"" + } + switch anchor { + case .leading: break + case .trailing: attributes += " text-anchor=\"end\"" + case .center: attributes += " text-anchor=\"middle\"" + } + attributes += Self.paint("fill", color.cgColor) + append("\(Self.escaped(shown))") + } + + func drawSymbol(named name: String, pointSize: CGFloat, color: NSColor, at point: CGPoint, anchor: ERDiagramTextAnchor) { + guard let glyph = ERDiagramSymbolRenderer.glyph(named: name, pointSize: pointSize, color: color), + let id = symbolId(SymbolKey(name: name, pointSize: pointSize, color: color), glyph: glyph) + else { return } + let frame = ERDiagramSymbolRenderer.frame(of: glyph, at: point, anchor: anchor) + append("") + } + + func document(size: CGSize, title: String) -> String { + let width = Self.number(size.width) + let height = Self.number(size.height) + var lines = [ + "", + "", + " \(Self.escaped(title))", + ] + if !defs.isEmpty { + lines.append(" ") + lines += defs.map { " " + $0 } + lines.append(" ") + } + lines += body.map { " " + $0 } + lines.append("") + return lines.joined(separator: "\n") + "\n" + } + + // MARK: - Output + + private func append(_ element: String) { + body.append(String(repeating: " ", count: depth) + element) + } + + private func group(_ open: String, _ draw: () -> Void) { + append(open) + depth += 1 + draw() + depth -= 1 + append("") + } + + private func symbolId(_ key: SymbolKey, glyph: ERDiagramSymbolRenderer.Glyph) -> String? { + if let existing = symbolIds[key] { return existing } + guard let image = Self.rasterize(glyph) else { return nil } + let id = "symbol\(symbolIds.count + 1)" + symbolIds[key] = id + defs.append( + "" + ) + return id + } + + private static func rasterize(_ glyph: ERDiagramSymbolRenderer.Glyph) -> (png: Data, size: CGSize)? { + let pixelsWide = max(1, Int((glyph.size.width * symbolScale).rounded(.up))) + let pixelsHigh = max(1, Int((glyph.size.height * symbolScale).rounded(.up))) + guard let space = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: pixelsWide, + height: pixelsHigh, + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { return nil } + + context.scaleBy(x: symbolScale, y: symbolScale) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false) + glyph.image.draw(in: CGRect(origin: .zero, size: glyph.size), from: .zero, operation: .sourceOver, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + + guard let cgImage = context.makeImage(), + let png = NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:]) + else { return nil } + let size = CGSize(width: CGFloat(pixelsWide) / symbolScale, height: CGFloat(pixelsHigh) / symbolScale) + return (png, size) + } + + // MARK: - Encoding + + nonisolated static func pathData(_ path: CGPath) -> String { + var commands: [String] = [] + path.applyWithBlock { element in + let points = element.pointee.points + switch element.pointee.type { + case .moveToPoint: + commands.append("M\(number(points[0].x)) \(number(points[0].y))") + case .addLineToPoint: + commands.append("L\(number(points[0].x)) \(number(points[0].y))") + case .addQuadCurveToPoint: + commands.append( + "Q\(number(points[0].x)) \(number(points[0].y)) \(number(points[1].x)) \(number(points[1].y))" + ) + case .addCurveToPoint: + commands.append( + "C\(number(points[0].x)) \(number(points[0].y)) \(number(points[1].x)) \(number(points[1].y))" + + " \(number(points[2].x)) \(number(points[2].y))" + ) + case .closeSubpath: + commands.append("Z") + @unknown default: + break + } + } + return commands.joined(separator: " ") + } + + // Two decimals is a hundredth of a point, finer than any renderer places a stroke. + nonisolated static func number(_ value: CGFloat, decimals: Int = 2) -> String { + var text = String(format: "%.\(decimals)f", Double(value)) + if text.contains(".") { + while text.hasSuffix("0") { text.removeLast() } + if text.hasSuffix(".") { text.removeLast() } + } + return text == "-0" ? "0" : text + } + + nonisolated static func paint(_ attribute: String, _ color: CGColor) -> String { + guard let space = CGColorSpace(name: CGColorSpace.sRGB), + let converted = color.converted(to: space, intent: .defaultIntent, options: nil), + let components = converted.components, components.count >= 4 + else { return " \(attribute)=\"#000000\"" } + + let hex = components.prefix(3) + .map { String(format: "%02X", Int((min(max($0, 0), 1) * 255).rounded())) } + .joined() + let alpha = min(max(components[3], 0), 1) + guard alpha < 1 else { return " \(attribute)=\"#\(hex)\"" } + return " \(attribute)=\"#\(hex)\" \(attribute)-opacity=\"\(number(alpha, decimals: 3))\"" + } + + nonisolated static func cssWeight(of font: NSFont) -> Int { + let traits = font.fontDescriptor.object(forKey: .traits) as? [NSFontDescriptor.TraitKey: Any] + let weight = (traits?[.weight] as? NSNumber)?.doubleValue ?? 0 + let scale: [(trait: Double, css: Int)] = [ + (-0.8, 100), (-0.6, 200), (-0.4, 300), (0, 400), (0.23, 500), (0.3, 600), (0.4, 700), (0.56, 800), (0.62, 900), + ] + return scale.min { abs($0.trait - weight) < abs($1.trait - weight) }?.css ?? 400 + } + + // Characters XML 1.0 cannot hold become U+FFFD, so any table name gives a well-formed file. + nonisolated static func escaped(_ text: String) -> String { + var result = "" + result.reserveCapacity(text.count) + for scalar in text.unicodeScalars { + switch scalar { + case "&": result += "&" + case "<": result += "<" + case ">": result += ">" + case "\"": result += """ + case "'": result += "'" + case "\t", "\n", "\r": result.unicodeScalars.append(scalar) + default: + let value = scalar.value + let allowed = (0x20...0xD7FF).contains(value) || (0xE000...0xFFFD).contains(value) || value >= 0x10000 + result.unicodeScalars.append(allowed ? scalar : "\u{FFFD}") + } + } + return result + } +} diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramScene.swift b/apple/TablePro/Views/ERDiagram/ERDiagramScene.swift index 060344993b..160cd87516 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramScene.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramScene.swift @@ -1,12 +1,3 @@ -// -// ERDiagramScene.swift -// TablePro -// -// Everything the diagram needs to paint one frame, and the two ways it is painted: into a view -// and into an export bitmap. One renderer serves both, so an exported PNG cannot drift from -// what is on screen. -// - import AppKit struct ERDiagramScene { @@ -23,9 +14,7 @@ struct ERDiagramScene { enum ERDiagramSceneRenderer { static let exportPadding: CGFloat = 40 - /// Everything the scene puts on the canvas, which is more than the tables: a self-referencing - /// table loops out past its own trailing edge, and cropping the export to the table rects alone - /// cuts the apex off. + // A self-referencing table loops out past its trailing edge. static func drawnBounds(of scene: ERDiagramScene) -> CGRect { var bounds = scene.nodeRects.values.reduce(CGRect.null) { $0.union($1) } @@ -39,15 +28,13 @@ enum ERDiagramSceneRenderer { return bounds } - /// The context is expected to be flipped, which is what both callers hand it: the diagram view - /// is `isFlipped`, and the export builds a flipped `NSGraphicsContext` over its bitmap. - static func draw(_ scene: ERDiagramScene, palette: ERDiagramPalette, dirtyRect: CGRect, in context: CGContext) { + static func draw(_ scene: ERDiagramScene, palette: ERDiagramPalette, dirtyRect: CGRect, on canvas: some ERDiagramCanvas) { ERDiagramEdgeRenderer.drawEdges( edges: scene.edges, nodeRects: scene.nodeRects, nodeIndex: scene.nodeIndex, strokeColor: palette.edge, - in: context + on: canvas ) for node in scene.nodes { @@ -58,24 +45,67 @@ enum ERDiagramSceneRenderer { isSelected: scene.selectedNodeId == node.id, clusterColor: scene.clusterColors[node.id], palette: palette, - in: context + on: canvas ) } } - /// Renders at natural scale whatever the viewport is doing, cropped to the nodes rather than to - /// the canvas, which is what the export has always promised. - /// - /// An offscreen bitmap has no appearance of its own, so the canvas's appearance is made current - /// for the whole render, and the palette is baked in it, or a dark or themed window exports in - /// other colors than the screen shows. - static func image(_ scene: ERDiagramScene, appearance: NSAppearance, scale: CGFloat) -> NSImage? { + // MARK: - Exports + + private struct ExportFrame { + let size: CGSize + let origin: CGPoint + let dirtyRect: CGRect + } + + private static func exportFrame(of scene: ERDiagramScene) -> ExportFrame { let bounds = drawnBounds(of: scene) - let size = bounds.isNull - ? CGSize(width: 100, height: 100) - : CGSize(width: bounds.width + exportPadding * 2, height: bounds.height + exportPadding * 2) - let pixelsWide = Int((size.width * scale).rounded()) - let pixelsHigh = Int((size.height * scale).rounded()) + guard !bounds.isNull else { + return ExportFrame(size: CGSize(width: 100, height: 100), origin: .zero, dirtyRect: .infinite) + } + return ExportFrame( + size: CGSize(width: bounds.width + exportPadding * 2, height: bounds.height + exportPadding * 2), + origin: CGPoint(x: exportPadding - bounds.minX, y: exportPadding - bounds.minY), + dirtyRect: bounds + ) + } + + private static func unselected(_ scene: ERDiagramScene) -> ERDiagramScene { + var flat = scene + flat.selectedNodeId = nil + return flat + } + + // An offscreen context has no appearance, so the canvas's is made current for the whole render. + private static func renderExport( + _ scene: ERDiagramScene, + frame: ExportFrame, + appearance: NSAppearance, + into cgContext: CGContext + ) { + cgContext.translateBy(x: 0, y: frame.size.height) + cgContext.scaleBy(x: 1, y: -1) + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + NSGraphicsContext.current = NSGraphicsContext(cgContext: cgContext, flipped: true) + + let palette = ERDiagramPalette(appearance: appearance) + let canvas = ERDiagramContextCanvas(context: cgContext) + appearance.performAsCurrentDrawingAppearance { + canvas.fill(CGPath(rect: CGRect(origin: .zero, size: frame.size), transform: nil), color: palette.backdrop) + + cgContext.saveGState() + cgContext.translateBy(x: frame.origin.x, y: frame.origin.y) + draw(unselected(scene), palette: palette, dirtyRect: frame.dirtyRect, on: canvas) + cgContext.restoreGState() + } + } + + static func image(_ scene: ERDiagramScene, appearance: NSAppearance, scale: CGFloat) -> NSImage? { + let frame = exportFrame(of: scene) + let pixelsWide = Int((frame.size.width * scale).rounded()) + let pixelsHigh = Int((frame.size.height * scale).rounded()) guard pixelsWide > 0, pixelsHigh > 0 else { return nil } guard let rep = NSBitmapImageRep( @@ -90,34 +120,46 @@ enum ERDiagramSceneRenderer { bytesPerRow: 0, bitsPerPixel: 0 ) else { return nil } - rep.size = size + rep.size = frame.size guard let base = NSGraphicsContext(bitmapImageRep: rep) else { return nil } - let cgContext = base.cgContext + renderExport(scene, frame: frame, appearance: appearance, into: base.cgContext) - NSGraphicsContext.saveGraphicsState() - defer { NSGraphicsContext.restoreGraphicsState() } - cgContext.translateBy(x: 0, y: size.height) - cgContext.scaleBy(x: 1, y: -1) - NSGraphicsContext.current = NSGraphicsContext(cgContext: cgContext, flipped: true) + let image = NSImage(size: frame.size) + image.addRepresentation(rep) + return image + } + + static func pdfData(_ scene: ERDiagramScene, appearance: NSAppearance, title: String) -> Data? { + let frame = exportFrame(of: scene) + let data = NSMutableData() + guard let consumer = CGDataConsumer(data: data as CFMutableData) else { return nil } + var mediaBox = CGRect(origin: .zero, size: frame.size) + let info: [CFString: Any] = [ + kCGPDFContextTitle: title, + kCGPDFContextCreator: "TablePro", + ] + guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, info as CFDictionary) else { return nil } + context.beginPDFPage(nil) + context.saveGState() + renderExport(scene, frame: frame, appearance: appearance, into: context) + context.restoreGState() + context.endPDFPage() + context.closePDF() + return data as Data + } + + static func svgDocument(_ scene: ERDiagramScene, appearance: NSAppearance, title: String) -> String { + let frame = exportFrame(of: scene) let palette = ERDiagramPalette(appearance: appearance) + let canvas = ERDiagramSVGCanvas() appearance.performAsCurrentDrawingAppearance { - cgContext.setFillColor(palette.backdrop) - cgContext.fill(CGRect(origin: .zero, size: size)) - - cgContext.saveGState() - if !bounds.isNull { - cgContext.translateBy(x: -bounds.minX + exportPadding, y: -bounds.minY + exportPadding) + canvas.fill(CGPath(rect: CGRect(origin: .zero, size: frame.size), transform: nil), color: palette.backdrop) + canvas.translated(by: frame.origin) { + draw(unselected(scene), palette: palette, dirtyRect: frame.dirtyRect, on: canvas) } - var flat = scene - flat.selectedNodeId = nil - draw(flat, palette: palette, dirtyRect: bounds.isNull ? .infinite : bounds, in: cgContext) - cgContext.restoreGState() } - - let image = NSImage(size: size) - image.addRepresentation(rep) - return image + return canvas.document(size: frame.size, title: title) } } diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramSceneView.swift b/apple/TablePro/Views/ERDiagram/ERDiagramSceneView.swift index f8492f4814..7813ecf13a 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramSceneView.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramSceneView.swift @@ -109,7 +109,7 @@ final class ERDiagramSceneView: NSView { override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } - ERDiagramSceneRenderer.draw(scene, palette: palette, dirtyRect: dirtyRect, in: context) + ERDiagramSceneRenderer.draw(scene, palette: palette, dirtyRect: dirtyRect, on: ERDiagramContextCanvas(context: context)) } override func viewDidChangeEffectiveAppearance() { diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramToolbar.swift b/apple/TablePro/Views/ERDiagram/ERDiagramToolbar.swift index 82469eaecd..dd38f011e5 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramToolbar.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramToolbar.swift @@ -3,7 +3,8 @@ import SwiftUI struct ERDiagramToolbar: View { @ObservedObject var viewModel: ERDiagramViewModel @ObservedObject var viewport: DiagramViewportController - let onExport: () -> Void + let onExport: (ERDiagramExportFormat) -> Void + let onCopyMermaid: () -> Void var body: some View { DiagramZoomToolbar(viewport: viewport) { @@ -35,12 +36,32 @@ struct ERDiagramToolbar: View { .help(String(localized: "Reset Layout")) .accessibilityLabel(String(localized: "Reset Layout")) - Button(action: onExport) { - Image(systemName: "square.and.arrow.up") - } - .help(String(localized: "Export as PNG")) - .accessibilityLabel(String(localized: "Export as PNG")) + exportMenu } .padding(12) } + + // Only `.contain` names an icon-only menu without hiding its items. + private var exportMenu: some View { + Menu { + ForEach(ERDiagramExportFormat.allCases) { format in + Button { + onExport(format) + } label: { + Text(verbatim: "\(format.name)\u{2026}") + } + } + Divider() + Button(String(localized: "Copy as Mermaid"), action: onCopyMermaid) + } label: { + Label(String(localized: "Export"), systemImage: "square.and.arrow.up") + .labelStyle(.iconOnly) + } + .menuIndicator(.hidden) + .fixedSize() + .help(String(localized: "Export")) + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Export")) + .accessibilityIdentifier("er-diagram-export") + } } diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramView.swift b/apple/TablePro/Views/ERDiagram/ERDiagramView.swift index 84901dff99..f088eac1aa 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -44,7 +44,12 @@ struct ERDiagramView: View { } else { diagramCanvas } - ERDiagramToolbar(viewModel: viewModel, viewport: viewport, onExport: exportDiagram) + ERDiagramToolbar( + viewModel: viewModel, + viewport: viewport, + onExport: export, + onCopyMermaid: copyMermaid + ) } } .task { await viewModel.loadDiagram() } @@ -52,8 +57,7 @@ struct ERDiagramView: View { // MARK: - Diagram Canvas - /// The scene is built here, during `body`, so observation sees every model property it reads; - /// the document view only receives the finished value. + // Built during `body`, so observation sees every model property the scene reads. private var diagramCanvas: some View { let scene = self.scene let actions = canvasActions @@ -110,21 +114,47 @@ struct ERDiagramView: View { return colors } - // MARK: - Export Rendering + // MARK: - Export private func exportImage() -> NSImage? { ERDiagramSceneRenderer.image( scene, appearance: viewport.effectiveAppearance, - scale: DiagramImageExporter.renderScale + scale: DiagramExporter.renderScale ) } - private func exportDiagram() { - DiagramImageExporter.export( - exportImage(), - defaultFileName: "er-diagram.png", + private var exportTitle: String { + viewModel.schemaName.map { "\(viewModel.databaseName).\($0)" } ?? viewModel.databaseName + } + + private func exportData(_ format: ERDiagramExportFormat) -> Data? { + switch format { + case .png: + return DiagramExporter.pngData(exportImage()) + case .svg: + let svg = ERDiagramSceneRenderer.svgDocument(scene, appearance: viewport.effectiveAppearance, title: exportTitle) + return Data(svg.utf8) + case .pdf: + return ERDiagramSceneRenderer.pdfData(scene, appearance: viewport.effectiveAppearance, title: exportTitle) + case .mermaid: + return Data(ERDiagramMermaidWriter.document(for: viewModel.graph).utf8) + case .graphviz: + return Data(ERDiagramDOTWriter.document(for: viewModel.graph).utf8) + } + } + + private func export(_ format: ERDiagramExportFormat) { + DiagramExporter.save( + exportData(format), + contentType: format.contentType, + formatName: format.name, + defaultFileName: "er-diagram.\(format.fileExtension)", title: String(localized: "Export ER Diagram") ) } + + private func copyMermaid() { + ClipboardService.shared.writeText(ERDiagramMermaidWriter.markdownBlock(for: viewModel.graph)) + } } diff --git a/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift index 0bd13ab623..85d81bff4e 100644 --- a/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift +++ b/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -33,7 +33,7 @@ struct QueryPlanDiagramView: View { DiagramZoomToolbar(viewport: viewport) { Divider().frame(height: 16) Button { - DiagramImageExporter.export( + DiagramExporter.export( exportCanvas, defaultFileName: "query-plan.png", title: String(localized: "Export Query Plan") @@ -50,7 +50,7 @@ struct QueryPlanDiagramView: View { // MARK: - Canvas - /// The selection is read here, during `body`, so a change to it re-renders the canvas. + // The selection is read here, during `body`, so a change to it re-renders the canvas. private var canvas: some View { let layout = layout let selectedNodeId = selectedNodeId @@ -64,7 +64,7 @@ struct QueryPlanDiagramView: View { updateDocument: { canvasView in canvasView.update(layout: layout, selectedNodeId: selectedNodeId) { selection.wrappedValue = $0 } canvasView.copyImage = { - guard let image = DiagramImageExporter.image(of: exportCanvas) else { return } + guard let image = DiagramExporter.image(of: exportCanvas) else { return } ClipboardService.shared.writeImage(image) } } diff --git a/apple/TableProTests/Models/ERDiagram/ERDiagramDOTWriterTests.swift b/apple/TableProTests/Models/ERDiagram/ERDiagramDOTWriterTests.swift new file mode 100644 index 0000000000..f96a56f1fa --- /dev/null +++ b/apple/TableProTests/Models/ERDiagram/ERDiagramDOTWriterTests.swift @@ -0,0 +1,166 @@ +import Foundation +@testable import TablePro +import Testing + +struct ERDiagramDOTWriterTests { + private func column( + _ name: String, + type: String = "integer", + nullable: Bool = false, + primaryKey: Bool = false + ) -> ColumnInfo { + ColumnInfo(name: name, dataType: type, isNullable: nullable, isPrimaryKey: primaryKey) + } + + private func foreignKey(_ name: String, column: String, references table: String, _ refColumn: String = "id") -> ForeignKeyInfo { + ForeignKeyInfo(name: name, column: column, referencedTable: table, referencedColumn: refColumn) + } + + private var shop: ERDiagramGraph { + ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true), column("email", type: "text")], + "orders": [column("id", primaryKey: true), column("user_id", nullable: true)], + ], + allForeignKeys: ["orders": [foreignKey("orders_user_id_fkey", column: "user_id", references: "users")]] + ) + } + + @Test("Each table is an HTML-like label with one row per column") + func tableLabels() { + let document = ERDiagramDOTWriter.document(for: shop) + + #expect(document.hasPrefix("digraph \"ER Diagram\" {\n")) + #expect(document.contains(""" + "orders" [label=< + + + + +
orders
id integer PK
user_id integer FK
+ >]; + """)) + #expect(document.hasSuffix("}\n")) + } + + @Test("A foreign key is an edge between the two column ports") + func edgeBetweenPorts() { + #expect(ERDiagramDOTWriter.document(for: shop).contains( + " \"orders\":\"user_id\" -> \"users\":\"id\" [arrowtail=crowodot, arrowhead=tee];\n" + )) + } + + @Test("Each cardinality draws Graphviz's crow, tee and odot as the canvas draws its markers", arguments: [ + (ERCardinality.oneToOne, "teetee", "tee"), + (.zeroOrOneToOne, "teeodot", "tee"), + (.manyToOne, "crowtee", "tee"), + (.zeroOrManyToOne, "crowodot", "tee"), + (.manyToMany, "crow", "crow"), + ]) + func arrows(cardinality: ERCardinality, tail: String, head: String) { + let arrows = ERDiagramDOTWriter.arrows(cardinality) + + #expect(arrows.tail == tail) + #expect(arrows.head == head) + } + + @Test("An edge to a column the view hides meets the table instead of a missing port") + func hiddenColumnHasNoPort() { + var graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true), column("email", type: "text")], + "orders": [column("id", primaryKey: true), column("user_email", type: "text")], + ], + allForeignKeys: ["orders": [foreignKey("fk", column: "user_email", references: "users", "email")]] + ) + graph.nodes = graph.nodes.map { node in + var compact = node + compact.displayColumns = node.columns.filter { $0.isPrimaryKey || $0.isForeignKey } + return compact + } + let document = ERDiagramDOTWriter.document(for: graph) + + #expect(document.contains(" \"orders\":\"user_email\" -> \"users\" [arrowtail=crowtee, arrowhead=tee];\n")) + #expect(!document.contains("PORT=\"email\"")) + } + + @Test("A collapsed junction table is one edge with a crow at each end") + func collapsedJunction() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "roles": [column("id", primaryKey: true)], + "user_roles": [column("user_id", primaryKey: true), column("role_id", primaryKey: true)], + ], + allForeignKeys: ["user_roles": [ + foreignKey("ur_user", column: "user_id", references: "users"), + foreignKey("ur_role", column: "role_id", references: "roles"), + ]] + ).projected(collapseJunctions: true) + + #expect(ERDiagramDOTWriter.document(for: graph).contains( + " \"users\" -> \"roles\" [arrowtail=crow, arrowhead=crow];\n" + )) + } + + @Test("Names are escaped for DOT and for the label's XML") + func escaping() { + let graph = ERDiagramGraphBuilder.build( + allColumns: ["a\"b&d": [column("x\"y", type: "t<1>", primaryKey: true), column("back\\slash")]], + allForeignKeys: [:] + ) + let document = ERDiagramDOTWriter.document(for: graph) + + #expect(document.contains(" \"a\\\"b&d\" [label=<\n")) + #expect(document.contains("a"b<c>&d")) + #expect(document.contains("PORT=\"x_y\">x"y t<1>")) + #expect(document.contains("back\\\\slash")) + } + + @Test("A backslash is doubled in a label, so \\N and \\G stay text instead of the node and graph names") + func backslashInLabel() { + #expect(ERDiagramDOTWriter.html("a\\Nb \\G") == "a\\\\Nb \\\\G") + } + + @Test("A port is the column name cut to letters, digits and underscores, unique in its table") + func portIds() { + #expect(ERDiagramDOTWriter.portIds(for: ["id", "id:s", "id_s", "名前", "user id", ""]) == [ + "id": "id", "id:s": "id_s", "id_s": "id_s_2", "名前": "_", "user id": "user_id", "": "_2", + ]) + } + + @Test("A colon in a column name never reaches Graphviz as a compass point") + func colonInColumnName() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "parent": [column("id", primaryKey: true), column("id:s", type: "text")], + "child": [column("id", primaryKey: true), column("parent_ref", type: "text")], + ], + allForeignKeys: ["child": [foreignKey("fk", column: "parent_ref", references: "parent", "id:s")]] + ) + let document = ERDiagramDOTWriter.document(for: graph) + + #expect(document.contains("id:s")) + #expect(document.contains(" \"child\":\"parent_ref\" -> \"parent\":\"id_s\" [arrowtail=crowtee, arrowhead=tee];\n")) + } + + @Test("The same schema writes the same file whatever order its edges arrive in") + func stableOrder() { + var graph = ERDiagramGraphBuilder.build( + allColumns: [ + "a": [column("id", primaryKey: true), column("b_id"), column("c_id")], + "b": [column("id", primaryKey: true)], + "c": [column("id", primaryKey: true), column("b_id")], + ], + allForeignKeys: [ + "a": [foreignKey("a_b", column: "b_id", references: "b"), foreignKey("a_c", column: "c_id", references: "c")], + "c": [foreignKey("c_b", column: "b_id", references: "b")], + ] + ) + let first = ERDiagramDOTWriter.document(for: graph) + graph.edges.reverse() + graph.nodes.reverse() + + #expect(ERDiagramDOTWriter.document(for: graph) == first) + } +} diff --git a/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift b/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift index 4dabad580f..949c5e4f29 100644 --- a/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift +++ b/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift @@ -293,4 +293,21 @@ struct ERDiagramGraphBuilderTests { #expect(expanded.edges.count == 2) #expect(!expanded.edges.contains { $0.cardinality == .manyToMany }) } + + // MARK: - Order + + @Test("Edges come out in table order, not in the order a dictionary happens to hold them") + func edgesFollowTableOrder() { + let tables = (0..<16).map { String(format: "t%02d", $0) } + var columns: [String: [ColumnInfo]] = ["parent": [column("id", primaryKey: true)]] + var foreignKeys: [String: [ForeignKeyInfo]] = [:] + for table in tables { + columns[table] = [column("id", primaryKey: true), column("parent_id")] + foreignKeys[table] = [foreignKey("\(table)_fk", column: "parent_id", references: "parent")] + } + + let graph = ERDiagramGraphBuilder.build(allColumns: columns, allForeignKeys: foreignKeys) + + #expect(graph.edges.map { $0.fromTable } == tables) + } } diff --git a/apple/TableProTests/Models/ERDiagram/ERDiagramMermaidWriterTests.swift b/apple/TableProTests/Models/ERDiagram/ERDiagramMermaidWriterTests.swift new file mode 100644 index 0000000000..796fd0741b --- /dev/null +++ b/apple/TableProTests/Models/ERDiagram/ERDiagramMermaidWriterTests.swift @@ -0,0 +1,199 @@ +import Foundation +@testable import TablePro +import Testing + +struct ERDiagramMermaidWriterTests { + private func column( + _ name: String, + type: String = "integer", + nullable: Bool = false, + primaryKey: Bool = false + ) -> ColumnInfo { + ColumnInfo(name: name, dataType: type, isNullable: nullable, isPrimaryKey: primaryKey) + } + + private func foreignKey(_ name: String, column: String, references table: String, _ refColumn: String = "id") -> ForeignKeyInfo { + ForeignKeyInfo(name: name, column: column, referencedTable: table, referencedColumn: refColumn) + } + + private var shop: ERDiagramGraph { + ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true), column("email", type: "character varying(255)")], + "orders": [ + column("id", primaryKey: true), + column("user_id"), + column("total", type: "numeric(10,2)", nullable: true), + ], + ], + allForeignKeys: ["orders": [foreignKey("orders_user_id_fkey", column: "user_id", references: "users")]] + ) + } + + @Test("Each table lists its columns with types and keys, then each foreign key as a relationship") + func writesTablesAndRelationships() { + #expect(ERDiagramMermaidWriter.document(for: shop) == """ + erDiagram + "orders" { + integer id PK + integer user_id FK + numeric(10_2) total + } + "users" { + integer id PK + character_varying(255) email + } + "orders" }|--|| "users" : "user_id" + + """) + } + + @Test("Each cardinality carries the markers the canvas draws at each end", arguments: [ + (ERCardinality.oneToOne, "||--||"), + (.zeroOrOneToOne, "|o--||"), + (.manyToOne, "}|--||"), + (.zeroOrManyToOne, "}o--||"), + (.manyToMany, "}o--o{"), + ]) + func cardinalityMarkers(cardinality: ERCardinality, expected: String) { + #expect(ERDiagramMermaidWriter.relationship(cardinality) == expected) + } + + @Test("A column that is both keys is marked PK, FK") + func primaryAndForeignKey() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "user_settings": [column("user_id", primaryKey: true)], + ], + allForeignKeys: ["user_settings": [foreignKey("fk", column: "user_id", references: "users")]] + ) + let document = ERDiagramMermaidWriter.document(for: graph) + + #expect(document.contains(" integer user_id PK, FK\n")) + #expect(document.contains(" \"user_settings\" ||--|| \"users\" : \"user_id\"\n")) + } + + @Test("A word the Mermaid lexer cannot read is fitted to one it can", arguments: [ + ("character varying(255)", "character_varying(255)"), + ("timestamp with time zone", "timestamp_with_time_zone"), + ("numeric(10,2)", "numeric(10_2)"), + ("text[]", "text[]"), + ("enum('a','b')", "enum(_a_b_)"), + ("2fa_secret", "_2fa_secret"), + ("pk", "_pk"), + ("FK", "_FK"), + ("uk-1", "_uk-1"), + ("pkey", "pkey"), + ("fk_user", "fk_user"), + ("user id", "user_id"), + ("名前", "名前"), + ("", "_"), + ]) + func attributeWords(input: String, expected: String) { + #expect(ERDiagramMermaidWriter.attributeWord(input) == expected) + } + + @Test("Table names are quoted, so keywords and spaces stay names") + func entityNames() { + #expect(ERDiagramMermaidWriter.entityName("end") == "\"end\"") + #expect(ERDiagramMermaidWriter.entityName("order items") == "\"order items\"") + #expect(ERDiagramMermaidWriter.entityName("a\"b%c\\d") == "\"a_b_c_d\"") + } + + @Test("Two tables that fit to the same name stay two entities") + func fittedNamesStayUnique() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "a%b": [column("id", primaryKey: true), column("ref_id")], + "a_b": [column("id", primaryKey: true)], + ], + allForeignKeys: ["a%b": [foreignKey("fk", column: "ref_id", references: "a_b")]] + ) + let document = ERDiagramMermaidWriter.document(for: graph) + + #expect(ERDiagramMermaidWriter.entityNames(for: ["a_b", "a%b", "a_b_2"]) + == ["a%b": "\"a_b\"", "a_b": "\"a_b_2\"", "a_b_2": "\"a_b_2_2\""]) + #expect(document.contains(" \"a_b\" {\n")) + #expect(document.contains(" \"a_b_2\" {\n")) + #expect(document.contains(" \"a_b\" }|--|| \"a_b_2\" : \"ref_id\"\n")) + } + + @Test("A name holding a direction directive is not read as one", arguments: [ + ("sort direction LR", "\"sort direction_LR\""), + ("Direction\ttb", "\"Direction_tb\""), + ("direction", "\"direction\""), + ]) + func directionInNames(input: String, expected: String) { + #expect(ERDiagramMermaidWriter.entityName(input) == expected) + } + + @Test("The same schema writes the same file whatever order its edges arrive in") + func stableOrder() { + var graph = ERDiagramGraphBuilder.build( + allColumns: [ + "a": [column("id", primaryKey: true), column("b_id"), column("c_id")], + "b": [column("id", primaryKey: true)], + "c": [column("id", primaryKey: true), column("b_id")], + ], + allForeignKeys: [ + "a": [foreignKey("a_b", column: "b_id", references: "b"), foreignKey("a_c", column: "c_id", references: "c")], + "c": [foreignKey("c_b", column: "b_id", references: "b")], + ] + ) + let first = ERDiagramMermaidWriter.document(for: graph) + graph.edges.reverse() + graph.nodes.reverse() + + #expect(ERDiagramMermaidWriter.document(for: graph) == first) + } + + @Test("Compact mode writes only the columns the canvas shows") + func writesDisplayedColumns() { + var graph = shop + graph.nodes = graph.nodes.map { node in + var compact = node + compact.displayColumns = node.columns.filter { $0.isPrimaryKey || $0.isForeignKey } + return compact + } + let document = ERDiagramMermaidWriter.document(for: graph) + + #expect(!document.contains("email")) + #expect(!document.contains("total")) + #expect(document.contains("integer user_id FK")) + } + + @Test("A collapsed junction table is one many-to-many relationship named after it") + func collapsedJunction() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "roles": [column("id", primaryKey: true)], + "user_roles": [column("user_id", primaryKey: true), column("role_id", primaryKey: true)], + ], + allForeignKeys: ["user_roles": [ + foreignKey("ur_user", column: "user_id", references: "users"), + foreignKey("ur_role", column: "role_id", references: "roles"), + ]] + ).projected(collapseJunctions: true) + let document = ERDiagramMermaidWriter.document(for: graph) + + #expect(document.contains(" \"users\" }o--o{ \"roles\" : \"user_roles\"\n")) + #expect(!document.contains("\"user_roles\" {")) + } + + @Test("A table without columns is declared by name alone") + func tableWithoutColumns() { + let graph = ERDiagramGraphBuilder.build(allColumns: ["empty": []], allForeignKeys: [:]) + + #expect(ERDiagramMermaidWriter.document(for: graph) == "erDiagram\n \"empty\"\n") + } + + @Test("Copy as Mermaid fences the diagram for Markdown") + func markdownBlock() { + let block = ERDiagramMermaidWriter.markdownBlock(for: shop) + + #expect(block.hasPrefix("```mermaid\nerDiagram\n")) + #expect(block.hasSuffix("\n```\n")) + } +} diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift new file mode 100644 index 0000000000..117d5f2745 --- /dev/null +++ b/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift @@ -0,0 +1,245 @@ +import AppKit +import PDFKit +@testable import TablePro +import Testing + +@MainActor +struct ERDiagramVectorExportTests { + private static let title = "shop.public" + + private func column(_ name: String, type: String = "integer", primaryKey: Bool = false, foreignKey: Bool = false) -> ERColumnDisplay { + ERColumnDisplay( + id: name, + name: name, + dataType: type, + isPrimaryKey: primaryKey, + isForeignKey: foreignKey, + isNullable: false + ) + } + + private func node(_ name: String, columns: [ERColumnDisplay]) -> ERTableNode { + ERTableNode(id: UUID(), tableName: name, columns: columns, displayColumns: columns, clusterId: 0) + } + + private func scene(usersName: String = "users", emailName: String = "email", selected: Bool = false) -> ERDiagramScene { + let users = node(usersName, columns: [column("id", primaryKey: true), column(emailName, type: "text")]) + let orders = node("orders", columns: [column("id", primaryKey: true), column("user_id", foreignKey: true)]) + let width = ERDiagramLayout.nodeWidth + let usersRect = CGRect(x: 120, y: 80, width: width, height: ERDiagramLayout.estimateHeight(columnCount: 2)) + let ordersRect = CGRect(x: 120 + width + 120, y: 80, width: width, height: ERDiagramLayout.estimateHeight(columnCount: 2)) + let edge = EREdge( + id: UUID(), + fkName: "orders_user_id_fkey", + fromTable: "orders", + fromColumn: "user_id", + toTable: usersName, + toColumn: "id", + cardinality: .zeroOrManyToOne + ) + return ERDiagramScene( + nodes: [users, orders], + edges: [edge], + nodeRects: [users.id: usersRect, orders.id: ordersRect], + nodeIndex: [usersName: users.id, "orders": orders.id], + clusterColors: [users.id: .systemBlue, orders.id: .systemBlue], + selectedNodeId: selected ? users.id : nil, + size: CGSize(width: ordersRect.maxX + 80, height: ordersRect.maxY + 80) + ) + } + + private func expectedSize(of scene: ERDiagramScene) -> CGSize { + let bounds = ERDiagramSceneRenderer.drawnBounds(of: scene) + let padding = ERDiagramSceneRenderer.exportPadding + return CGSize(width: bounds.width + padding * 2, height: bounds.height + padding * 2) + } + + private func appearance() throws -> NSAppearance { + try #require(NSAppearance(named: .aqua)) + } + + private func svg(_ scene: ERDiagramScene) throws -> XMLElement { + let text = ERDiagramSceneRenderer.svgDocument(scene, appearance: try appearance(), title: Self.title) + let document = try XMLDocument(data: Data(text.utf8)) + return try #require(document.rootElement()) + } + + private func elements(named name: String, in root: XMLElement) -> [XMLElement] { + let children = (root.children ?? []).compactMap { $0 as? XMLElement } + return (root.localName == name ? [root] : []) + children.flatMap { elements(named: name, in: $0) } + } + + // MARK: - PDF + + @Test("The PDF is one page sized like the PNG: what is drawn plus the export padding") + func pdfIsOnePageSizedToTheDiagram() throws { + let scene = scene() + let data = try #require(ERDiagramSceneRenderer.pdfData(scene, appearance: try appearance(), title: Self.title)) + let document = try #require(PDFDocument(data: data)) + let page = try #require(document.page(at: 0)) + let size = expectedSize(of: scene) + + #expect(document.pageCount == 1) + #expect(abs(page.bounds(for: .mediaBox).width - size.width) < 0.5) + #expect(abs(page.bounds(for: .mediaBox).height - size.height) < 0.5) + #expect(document.documentAttributes?[PDFDocumentAttribute.titleAttribute] as? String == Self.title) + } + + @Test("The PDF keeps table names, columns and types as text") + func pdfKeepsTextAsText() throws { + let data = try #require(ERDiagramSceneRenderer.pdfData(scene(), appearance: try appearance(), title: Self.title)) + let text = try #require(PDFDocument(data: data)?.page(at: 0)?.string) + + for word in ["users", "orders", "email", "user_id", "integer", "text"] { + #expect(text.contains(word), "missing \(word)") + } + } + + @Test("A key badge draws upright in the flipped export, bow at the top as the SVG draws it") + func symbolsDrawUpright() throws { + let scale: CGFloat = 4 + let scene = scene() + let image = try #require(ERDiagramSceneRenderer.image(scene, appearance: try appearance(), scale: scale)) + let rep = try #require(image.representations.first as? NSBitmapImageRep) + let users = try #require(scene.nodes.first { $0.tableName == "users" }) + let rect = try #require(scene.nodeRects[users.id]) + let bounds = ERDiagramSceneRenderer.drawnBounds(of: scene) + let padding = ERDiagramSceneRenderer.exportPadding + let center = CGPoint( + x: (rect.minX - bounds.minX + padding + 14 * ERDiagramLayout.typeScale) * scale, + y: (rect.minY - bounds.minY + padding + ERDiagramLayout.headerHeight + ERDiagramLayout.columnRowHeight / 2) * scale + ) + + var upper = 0 + var lower = 0 + let reach = Int(8 * scale) + for dy in -reach...reach { + for dx in -reach...reach { + guard let color = rep.colorAt(x: Int(center.x) + dx, y: Int(center.y) + dy)?.usingColorSpace(.sRGB), + color.redComponent > 0.7, color.greenComponent > 0.5, color.blueComponent < 0.4 + else { continue } + if dy < 0 { upper += 1 } else if dy > 0 { lower += 1 } + } + } + + #expect(upper + lower > 0, "the key badge must be drawn") + #expect(upper > lower) + } + + // MARK: - SVG + + @Test("The SVG is well-formed and sized like the PNG") + func svgIsSizedToTheDiagram() throws { + let scene = scene() + let root = try svg(scene) + let size = expectedSize(of: scene) + + #expect(root.name == "svg") + #expect(root.attribute(forName: "width")?.stringValue == ERDiagramSVGCanvas.number(size.width)) + #expect(root.attribute(forName: "height")?.stringValue == ERDiagramSVGCanvas.number(size.height)) + #expect(elements(named: "title", in: root).first?.stringValue == Self.title) + } + + @Test("The SVG writes every table name, column and type as text") + func svgKeepsTextAsText() throws { + let texts = try elements(named: "text", in: svg(scene())).compactMap(\.stringValue) + + for word in ["users", "orders", "id", "email", "user_id", "integer", "text"] { + #expect(texts.contains(word), "missing \(word)") + } + #expect(texts.count == 2 + 2 * 4) + } + + @Test("The SVG draws an edge as its curve and the markers the canvas draws") + func svgDrawsEdgeAndMarkers() throws { + let paths = try elements(named: "path", in: svg(scene())) + let connectors = paths.filter { $0.attribute(forName: "stroke-linecap")?.stringValue == "round" } + + // The curve, the crow's foot and circle at the foreign key, and the bar at the referenced end. + #expect(connectors.count == 4) + #expect(connectors.allSatisfy { $0.attribute(forName: "fill")?.stringValue == "none" }) + } + + @Test("The SVG embeds each symbol once and places it with a use") + func svgSymbolsAreShared() throws { + let root = try svg(scene()) + let images = elements(named: "image", in: root) + let uses = elements(named: "use", in: root) + + // tablecells, key.fill and link; two table icons, two keys and one link. + #expect(images.count == 3) + #expect(uses.count == 5) + #expect(images.allSatisfy { image in + image.attribute(forLocalName: "href", uri: "http://www.w3.org/1999/xlink")?.stringValue? + .hasPrefix("data:image/png;base64,") == true + }) + } + + @Test("The export never shows the selection") + func svgOmitsSelection() throws { + let paths = try elements(named: "path", in: svg(scene(selected: true))) + + #expect(!paths.contains { $0.attribute(forName: "stroke-width")?.stringValue == "2" }) + } + + @Test("Names XML cannot hold as they are still give a well-formed file") + func svgEscapesNames() throws { + let texts = try elements(named: "text", in: svg(scene(usersName: "a&b\"d", emailName: "e\u{1}f"))) + .compactMap(\.stringValue) + + #expect(texts.contains("a&b\"d")) + #expect(texts.contains("e\u{FFFD}f")) + } + + @Test("A column name too long for its row ends in the same ellipsis the canvas draws") + func svgTruncatesLikeTheCanvas() throws { + let long = String(repeating: "shipping_address_", count: 4) + let texts = try elements(named: "text", in: svg(scene(emailName: long))).compactMap(\.stringValue) + let shown = try #require(texts.first { $0.hasPrefix("shipping_address_") }) + + #expect(shown.hasSuffix("\u{2026}")) + #expect(shown.count < long.count) + } + + // MARK: - Encoding + + @Test("Text that fits is kept, and text that does not keeps the longest prefix that fits") + func fittedText() { + let font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + let text = "character varying(255)" + let full = ERDiagramTextRenderer.width(of: text, font: font) + let fitted = ERDiagramTextRenderer.fittedText(text, font: font, maxWidth: full / 2) + let longer = String(text.prefix(fitted.count)) + "\u{2026}" + + #expect(ERDiagramTextRenderer.fittedText(text, font: font, maxWidth: full) == text) + #expect(fitted.hasSuffix("\u{2026}")) + #expect(ERDiagramTextRenderer.width(of: fitted, font: font) <= full / 2) + #expect(ERDiagramTextRenderer.width(of: longer, font: font) > full / 2) + } + + @Test("A path is written with SVG's commands") + func pathData() { + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: 10.5, y: 0)) + path.addCurve(to: CGPoint(x: 20, y: 5), control1: CGPoint(x: 12, y: 1), control2: CGPoint(x: 18.333, y: 4)) + path.closeSubpath() + + #expect(ERDiagramSVGCanvas.pathData(path) == "M0 0 L10.5 0 C12 1 18.33 4 20 5 Z") + } + + @Test("Numbers are short and never negative zero", arguments: [ + (CGFloat(1), "1"), (1.5, "1.5"), (1.234, "1.23"), (-0.001, "0"), (120, "120"), + ]) + func numbers(value: CGFloat, expected: String) { + #expect(ERDiagramSVGCanvas.number(value) == expected) + } + + @Test("A translucent color is written as a hex color and an opacity") + func paint() throws { + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let color = try #require(CGColor(colorSpace: space, components: [1, 0.5, 0, 0.25])) + + #expect(ERDiagramSVGCanvas.paint("fill", color) == " fill=\"#FF8000\" fill-opacity=\"0.25\"") + } +} diff --git a/apple/changelog.d/3738.added.md b/apple/changelog.d/3738.added.md new file mode 100644 index 0000000000..ace5f93181 --- /dev/null +++ b/apple/changelog.d/3738.added.md @@ -0,0 +1 @@ +Export the ER diagram as SVG, PDF, Mermaid or Graphviz DOT, or copy it as Mermaid for Markdown. diff --git a/apple/changelog.d/3738.fixed.md b/apple/changelog.d/3738.fixed.md new file mode 100644 index 0000000000..fd285e9456 --- /dev/null +++ b/apple/changelog.d/3738.fixed.md @@ -0,0 +1 @@ +The key, link and junction table icons in the ER diagram and its PNG export were drawn upside down.