From ca9cb1bad2155e0d84256e464e7c4c52374cc534 Mon Sep 17 00:00:00 2001 From: fummicc1 Date: Wed, 16 Oct 2024 23:23:35 +0900 Subject: [PATCH] feat: Define ImageRenderer and separate build logic and render logic in generating image. --- Sources/Csv2ImgCore/Csv.swift | 29 +- Sources/Csv2ImgCore/CsvError.swift | 2 + .../Csv2ImgCore/CsvImageRepresentation.swift | 22 ++ Sources/Csv2ImgCore/ImageMaker.swift | 298 +++++------------- Sources/Csv2ImgCore/ImageRenderer.swift | 78 +++++ 5 files changed, 188 insertions(+), 241 deletions(-) create mode 100644 Sources/Csv2ImgCore/CsvImageRepresentation.swift create mode 100644 Sources/Csv2ImgCore/ImageRenderer.swift diff --git a/Sources/Csv2ImgCore/Csv.swift b/Sources/Csv2ImgCore/Csv.swift index 20ae5c3..ab3ef11 100644 --- a/Sources/Csv2ImgCore/Csv.swift +++ b/Sources/Csv2ImgCore/Csv.swift @@ -507,45 +507,32 @@ extension Csv { } if let maker = maker as? ImageMaker { if let fontSize = fontSize { - maker.set( - fontSize: fontSize - ) + maker.set(fontSize: fontSize) } - let exportable: any CsvExportable = try await withCheckedThrowingContinuation { + let image: CGImage = try await withCheckedThrowingContinuation { continuation in queue.async { [weak self] in guard let self = self else { - continuation.resume( - throwing: Csv.Error.underlying( - nil - ) - ) + continuation.resume(throwing: Csv.Error.underlying(nil)) return } Task { do { - let img = try maker.make( + let image = try maker.make( columns: await self.columns, rows: await self.rows ) { progress in self.progressSubject.value = progress } - continuation.resume( - returning: img - ) + continuation.resume(returning: image) } catch { - continuation.resume( - throwing: Csv.Error.underlying( - error - ) - ) + continuation.resume(throwing: Csv.Error.underlying(error)) } } } } - return AnyCsvExportable( - exportable - ) + + return AnyCsvExportable(image) } else if let maker = maker as? PdfMaker { if let fontSize = fontSize { maker.set( diff --git a/Sources/Csv2ImgCore/CsvError.swift b/Sources/Csv2ImgCore/CsvError.swift index 9288799..ac84e67 100644 --- a/Sources/Csv2ImgCore/CsvError.swift +++ b/Sources/Csv2ImgCore/CsvError.swift @@ -34,6 +34,8 @@ extension Csv { case emptyData /// Csv denied execution because it is generating another contents. case workInProgress + /// Failed to render image from `CsvImageRepresentation`. + case failedToRenderImage case underlying( Swift.Error? ) diff --git a/Sources/Csv2ImgCore/CsvImageRepresentation.swift b/Sources/Csv2ImgCore/CsvImageRepresentation.swift new file mode 100644 index 0000000..cfead4e --- /dev/null +++ b/Sources/Csv2ImgCore/CsvImageRepresentation.swift @@ -0,0 +1,22 @@ +import CoreGraphics +import Foundation + +public struct CsvImageRepresentation: Equatable { + let width: Int + let height: Int + let backgroundColor: CGColor + let fontSize: CGFloat + let columns: [ColumnRepresentation] + let rows: [RowRepresentation] + + struct ColumnRepresentation: Equatable { + let name: String + let style: Csv.Column.Style + let frame: CGRect + } + + struct RowRepresentation: Equatable { + let values: [String] + let frames: [CGRect] + } +} diff --git a/Sources/Csv2ImgCore/ImageMaker.swift b/Sources/Csv2ImgCore/ImageMaker.swift index 7ce711c..618c4dc 100644 --- a/Sources/Csv2ImgCore/ImageMaker.swift +++ b/Sources/Csv2ImgCore/ImageMaker.swift @@ -58,13 +58,40 @@ final class ImageMaker: ImageMakerType { } /// generate png-image data from ``Csv``. - func make( + internal func make( columns: [Csv.Column], rows: [Csv.Row], progress: @escaping ( Double ) -> Void ) throws -> CGImage { + let representation = try build(columns: columns, rows: rows, progress: progress) + guard + let context = CGContext( + data: nil, + width: representation.width, + height: representation.height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + throw ImageMakingError.noContextAvailable + } + guard let image = ImageRenderer().render(context: context, representation) else { + throw ImageMakingError.failedCreateImage(context) + } + return image + } + + internal func build( + columns: [Csv.Column], + rows: [Csv.Row], + progress: @escaping ( + Double + ) -> Void + ) throws -> CsvImageRepresentation { let length = min( maximumRowCount ?? rows.count, @@ -112,245 +139,76 @@ final class ImageMaker: ImageMakerType { longestHeight ) + verticalSpace) - #if os(macOS) - let canvas = NSImage( - size: NSSize( - width: width, - height: height - ) - ) - canvas.lockFocus() - guard let context = NSGraphicsContext.current?.cgContext else { - throw ImageMakingError.noContextAvailable - } - #elseif os(iOS) - UIGraphicsBeginImageContext( - CGSize( - width: width, - height: height - ) - ) - guard let context = UIGraphicsGetCurrentContext() else { - throw ImageMakingError.noContextAvailable - } - #endif - - defer { - #if os(macOS) - canvas.unlockFocus() - #elseif os(iOS) - UIGraphicsEndImageContext() - #endif - } - - context.setFillColor( - CGColor( - red: 250 / 255, - green: 250 / 255, - blue: 250 / 255, - alpha: 1 - ) - ) - context.fill( - CGRect( - origin: .zero, - size: CGSize( - width: width, - height: height - ) - ) - ) + let backgroundColor = CGColor(red: 250 / 255, green: 250 / 255, blue: 250 / 255, alpha: 1) - context.setLineWidth( - 1 - ) - #if os(macOS) - context.setStrokeColor( - Color.separatorColor.cgColor - ) - #elseif os(iOS) - context.setStrokeColor( - Color.separator.cgColor - ) - #endif - context.setFillColor( - CGColor( - red: 22 / 255, - green: 22 / 255, - blue: 22 / 255, - alpha: 1 - ) - ) + let columnWidth = width / columns.count + let rowHeight = height / (rows.count + 1) - let columnCount = columns.count - let rowCount = rows.count + 1 - let rowHeight = - Int( - height - ) / rowCount - let columnWidth = - Int( - width - ) / columnCount + var columnRepresentations: [CsvImageRepresentation.ColumnRepresentation] = [] + var rowRepresentations: [CsvImageRepresentation.RowRepresentation] = [] - let completeCount: Double = Double( - rowCount + columnCount - ) + let completeCount: Double = Double(rows.count + columns.count) var completeFraction: Double = 0 - for i in 0.. CGImage? { + let width = representation.width + let height = representation.height + + context.setFillColor(representation.backgroundColor) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + + // Draw grid lines + context.setLineWidth(1) + context.setStrokeColor(CGColor(gray: 0.8, alpha: 1)) + + for column in representation.columns { + context.move(to: CGPoint(x: column.frame.minX, y: 0)) + context.addLine(to: CGPoint(x: Int(column.frame.minX), y: height)) + } + context.move(to: CGPoint(x: width, y: 0)) + context.addLine(to: CGPoint(x: width, y: height)) + + for row in representation.rows { + let y = Int(row.frames.first?.minY ?? 0) + context.move(to: CGPoint(x: 0, y: y)) + context.addLine(to: CGPoint(x: width, y: y)) + } + context.move(to: CGPoint(x: 0, y: height)) + context.addLine(to: CGPoint(x: width, y: height)) + + context.strokePath() + + // Draw columns + for column in representation.columns { + drawText( + context: context, text: column.name, frame: column.frame, style: column.style, + fontSize: representation.fontSize) + } + + // Draw rows + for row in representation.rows { + for (index, (value, frame)) in zip(row.values, row.frames).enumerated() { + let column = representation.columns[index] + drawText( + context: context, text: value, frame: frame, style: column.style, + fontSize: representation.fontSize) + } + } + + return context.makeImage() + } + + private func drawText( + context: CGContext, text: String, frame: CGRect, style: Csv.Column.Style, fontSize: CGFloat + ) { + let attributes: [NSAttributedString.Key: Any] = [ + .font: Font.systemFont(ofSize: fontSize), + .foregroundColor: style.displayableColor(), + ] + + let attributedString = NSAttributedString(string: text, attributes: attributes) + let textSize = (text as NSString).size(withAttributes: attributes) + + let rect = CGRect( + x: frame.origin.x + (frame.width - textSize.width) / 2, + y: frame.origin.y + (frame.height - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + + context.saveGState() + context.translateBy(x: 0, y: CGFloat(context.height)) + context.scaleBy(x: 1.0, y: -1.0) + attributedString.draw(in: rect) + context.restoreGState() + } +}