diff --git a/Fixtures/outputs/category.png b/Fixtures/outputs/category.png index 42b3501..ec0c221 100644 Binary files a/Fixtures/outputs/category.png and b/Fixtures/outputs/category.png differ diff --git a/Sources/Csv2ImgCore/ImageMaker.swift b/Sources/Csv2ImgCore/ImageMaker.swift index 618c4dc..7a56799 100644 --- a/Sources/Csv2ImgCore/ImageMaker.swift +++ b/Sources/Csv2ImgCore/ImageMaker.swift @@ -57,6 +57,45 @@ final class ImageMaker: ImageMakerType { self.fontSize = size } + private func createContext( + width: Int, + height: Int + ) throws -> CGContext { + + #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 + } + + return context + } + /// generate png-image data from ``Csv``. internal func make( columns: [Csv.Column], @@ -66,22 +105,15 @@ final class ImageMaker: ImageMakerType { ) -> 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 { + let context = try createContext(width: representation.width, height: representation.height) + + let renderer = ImageRenderer() + let image = renderer.render(context: context, representation) + guard let image = image else { throw ImageMakingError.failedCreateImage(context) } + + self.latestOutput = image return image } @@ -103,46 +135,15 @@ final class ImageMaker: ImageMakerType { let horizontalSpace = 8 let verticalSpace = 12 - let textSizeList = - rows - .flatMap({ - $0.values - }) - .map({ - $0.getSize( - fontSize: fontSize - ) - }) - + columns - .map({ - $0.name - }) - .map({ - $0.getSize( - fontSize: fontSize - ) - }) - - let longestHeight = textSizeList.map({ - $0.height - }).sorted().reversed()[0] - let longestWidth = textSizeList.map({ - $0.width - }).sorted().reversed()[0] - let width = - (Int( - longestWidth - ) + horizontalSpace) * columns.count - let height = - (rows.count + 1) - * (Int( - longestHeight - ) + verticalSpace) let backgroundColor = CGColor(red: 250 / 255, green: 250 / 255, blue: 250 / 255, alpha: 1) - let columnWidth = width / columns.count - let rowHeight = height / (rows.count + 1) + let columnWidths = calculateColumnWidths(columns: columns, rows: rows) + let width = columnWidths.reduce(0, +) + (columns.count + 1) * horizontalSpace + + let rowHeights = calculateRowHeights( + columns: columns, rows: rows, columnWidths: columnWidths) + let height = rowHeights.reduce(0, +) + (rows.count + 2) * verticalSpace var columnRepresentations: [CsvImageRepresentation.ColumnRepresentation] = [] var rowRepresentations: [CsvImageRepresentation.RowRepresentation] = [] @@ -150,53 +151,39 @@ final class ImageMaker: ImageMakerType { let completeCount: Double = Double(rows.count + columns.count) var completeFraction: Double = 0 + var yOffset = verticalSpace + // ヘッダー行の描画 for (i, column) in columns.enumerated() { - let size = column.name.getSize(fontSize: fontSize) - let originX = i * columnWidth + columnWidth / 2 - Int(size.width) / 2 - let originY = height - Int(size.height) / 2 - rowHeight / 2 - + let xOffset = columnWidths[0.. [Int] { + return columns.enumerated().map { (index, column) in + let headerWidth = column.name.getSize(fontSize: fontSize).width + let maxContentWidth = + rows.map { row in + row.values.count > index + ? row.values[index].getSize(fontSize: fontSize).width : 0 + }.max() ?? 0 + return Int(max(headerWidth, maxContentWidth)) + 20 // 20はパディング + } + } + + private func calculateRowHeights(columns: [Csv.Column], rows: [Csv.Row], columnWidths: [Int]) + -> [Int] + { + let headerHeight = + Int(columns.map { $0.name.getSize(fontSize: fontSize).height }.max() ?? 0) + 10 + + let contentHeights = rows.map { row in + let maxHeight = + row.values.enumerated().map { (index, value) in + if index < columnWidths.count { + let size = value.getSize(fontSize: fontSize) + let lines = ceil(size.width / CGFloat(columnWidths[index])) + return size.height * lines + } + return 0 + }.max() ?? 0 + return Int(maxHeight) + 10 // 10はパディング + } + + return [headerHeight] + contentHeights + } } diff --git a/Sources/Csv2ImgCore/ImageRenderer.swift b/Sources/Csv2ImgCore/ImageRenderer.swift index 7465018..b5638ed 100644 --- a/Sources/Csv2ImgCore/ImageRenderer.swift +++ b/Sources/Csv2ImgCore/ImageRenderer.swift @@ -2,6 +2,12 @@ import CoreGraphics import CoreText import Foundation +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + public class ImageRenderer { public func render(context: CGContext, _ representation: CsvImageRepresentation) -> CGImage? { let width = representation.width @@ -10,9 +16,16 @@ public class ImageRenderer { 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)) + #if os(macOS) + context.setStrokeColor( + Color.separatorColor.cgColor + ) + #elseif os(iOS) + context.setStrokeColor( + Color.separator.cgColor + ) + #endif for column in representation.columns { context.move(to: CGPoint(x: column.frame.minX, y: 0)) @@ -31,14 +44,17 @@ public class ImageRenderer { context.strokePath() - // Draw columns + #if os(macOS) + context.translateBy(x: 0, y: CGFloat(height)) + context.scaleBy(x: 1.0, y: -1.0) + #endif + 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] @@ -60,9 +76,10 @@ public class ImageRenderer { ] let attributedString = NSAttributedString(string: text, attributes: attributes) - let textSize = (text as NSString).size(withAttributes: attributes) - let rect = CGRect( + let textSize = attributedString.size() + + let textRect = CGRect( x: frame.origin.x + (frame.width - textSize.width) / 2, y: frame.origin.y + (frame.height - textSize.height) / 2, width: textSize.width, @@ -71,12 +88,23 @@ public class ImageRenderer { context.saveGState() + // テキストの描画位置を設定 + let textPath = CGPath(rect: textRect, transform: nil) + context.addPath(textPath) + context.clip() + + // テキストを描画 #if os(macOS) - context.translateBy(x: 0, y: CGFloat(context.height)) - context.scaleBy(x: 1.0, y: -1.0) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) + attributedString.draw(in: textRect) + NSGraphicsContext.restoreGraphicsState() + #else + UIGraphicsPushContext(context) + attributedString.draw(in: textRect) + UIGraphicsPopContext() #endif - attributedString.draw(in: rect) context.restoreGState() } } diff --git a/Tests/Csv2ImgCoreTests/ImageMakerTests.swift b/Tests/Csv2ImgCoreTests/ImageMakerTests.swift index 71e9fae..89438af 100644 --- a/Tests/Csv2ImgCoreTests/ImageMakerTests.swift +++ b/Tests/Csv2ImgCoreTests/ImageMakerTests.swift @@ -5,18 +5,76 @@ import XCTest class ImageMakerTests: XCTestCase { func testBuildImage() async throws { // Given - let fileURL = getRelativeFilePathFromPackageSource( - path: "/Fixtures/outputs/category.png" - ) let csv = Csv.loadFromString( """ - name,beginnerValue,middleValue - Requirements Analysis,1.00,1 + name,beginnerValue,middleValue,expertValue,unit + Requirements Analysis,1.00,1.00,1.00,H + Concept Design,0.10,0.50,1.00,H + Detail Design,0.10,0.50,1.00,page """, styles: [ Csv.Column.Style(color: Color.blue.cgColor), Csv.Column.Style(color: Color.blue.cgColor), Csv.Column.Style(color: Color.blue.cgColor), + Csv.Column.Style(color: Color.blue.cgColor), + Csv.Column.Style(color: Color.blue.cgColor), + ] + ) + let expectedImageRepresentation = CsvImageRepresentation( + width: 546, + height: 160, + backgroundColor: CGColor(red: 0.980392, green: 0.980392, blue: 0.980392, alpha: 1), + fontSize: 12.0, + columns: [ + CsvImageRepresentation.ColumnRepresentation( + name: "name", + style: Csv.Column.Style(color: Color.blue.cgColor, applyOnlyColumn: false), + frame: CGRect(x: 8.0, y: 12.0, width: 158.0, height: 25.0)), + CsvImageRepresentation.ColumnRepresentation( + name: "beginnerValue", + style: Csv.Column.Style(color: Color.blue.cgColor, applyOnlyColumn: false), + frame: CGRect(x: 174.0, y: 12.0, width: 106.0, height: 25.0)), + CsvImageRepresentation.ColumnRepresentation( + name: "middleValue", + style: Csv.Column.Style(color: Color.blue.cgColor, applyOnlyColumn: false), + frame: CGRect(x: 288.0, y: 12.0, width: 93.0, height: 25.0)), + CsvImageRepresentation.ColumnRepresentation( + name: "expertValue", + style: Csv.Column.Style(color: Color.blue.cgColor, applyOnlyColumn: false), + frame: CGRect(x: 389.0, y: 12.0, width: 92.0, height: 25.0)), + CsvImageRepresentation.ColumnRepresentation( + name: "unit", + style: Csv.Column.Style(color: Color.blue.cgColor, applyOnlyColumn: false), + frame: CGRect(x: 489.0, y: 12.0, width: 49.0, height: 25.0)), + ], + rows: [ + CsvImageRepresentation.RowRepresentation( + values: ["Requirements Analysis", "1.00", "1.00", "1.00", "H"], + frames: [ + CGRect(x: 8.0, y: 49.0, width: 158.0, height: 25.0), + CGRect(x: 174.0, y: 49.0, width: 106.0, height: 25.0), + CGRect(x: 288.0, y: 49.0, width: 93.0, height: 25.0), + CGRect(x: 389.0, y: 49.0, width: 92.0, height: 25.0), + CGRect(x: 489.0, y: 49.0, width: 49.0, height: 25.0), + ]), + CsvImageRepresentation.RowRepresentation( + values: ["Concept Design", "0.10", "0.50", "1.00", "H"], + frames: [ + CGRect(x: 8.0, y: 86.0, width: 158.0, height: 25.0), + CGRect(x: 174.0, y: 86.0, width: 106.0, height: 25.0), + CGRect(x: 288.0, y: 86.0, width: 93.0, height: 25.0), + CGRect(x: 389.0, y: 86.0, width: 92.0, height: 25.0), + CGRect(x: 489.0, y: 86.0, width: 49.0, height: 25.0), + ]), + CsvImageRepresentation.RowRepresentation( + values: ["Detail Design", "0.10", "0.50", "1.00", "page"], + frames: [ + CGRect(x: 8.0, y: 123.0, width: 158.0, height: 25.0), + CGRect(x: 174.0, y: 123.0, width: 106.0, height: 25.0), + CGRect(x: 288.0, y: 123.0, width: 93.0, height: 25.0), + CGRect(x: 389.0, y: 123.0, width: 92.0, height: 25.0), + CGRect(x: 489.0, y: 123.0, width: 49.0, height: 25.0), + ]), ] ) let imageMaker = ImageMaker(maximumRowCount: nil, fontSize: 12) @@ -28,13 +86,42 @@ class ImageMakerTests: XCTestCase { rows: rows ) { double in } + // Then - print(imageRepresentation) - let cgImage = try imageMaker.make( - columns: columns, - rows: rows - ) { _ in + XCTAssertEqual(imageRepresentation.width, expectedImageRepresentation.width) + XCTAssertEqual(imageRepresentation.height, expectedImageRepresentation.height) + XCTAssertEqual(imageRepresentation.fontSize, expectedImageRepresentation.fontSize) + + // background colors + XCTAssertEqual( + imageRepresentation.backgroundColor.components?.count, + expectedImageRepresentation.backgroundColor.components?.count + ) + + if let actualComponents = imageRepresentation.backgroundColor.components, + let expectedComponents = expectedImageRepresentation.backgroundColor.components + { + for (actual, expected) in zip(actualComponents, expectedComponents) { + // allow 0.0001 difference + XCTAssertEqual(actual, expected, accuracy: 0.0001) + } + } + + // columns + XCTAssertEqual(imageRepresentation.columns.count, expectedImageRepresentation.columns.count) + for (actual, expected) in zip( + imageRepresentation.columns, expectedImageRepresentation.columns) + { + XCTAssertEqual(actual.name, expected.name) + XCTAssertEqual(actual.frame, expected.frame) + XCTAssertEqual(actual.style.color.components, expected.style.color.components) + } + + // rows + XCTAssertEqual(imageRepresentation.rows.count, expectedImageRepresentation.rows.count) + for (actual, expected) in zip(imageRepresentation.rows, expectedImageRepresentation.rows) { + XCTAssertEqual(actual.values, expected.values) + XCTAssertEqual(actual.frames, expected.frames) } - try cgImage.convertToData()?.write(to: fileURL) } }