From 70a24d436ffa3742989bc84d28dc7d94dca575be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Franze=CC=81n?= Date: Wed, 20 Mar 2024 12:17:55 +0100 Subject: [PATCH] Rename BasicText to Text and remove the previous rich Text which will return as a separate package. --- Sources/SwiftSCAD/Shapes/2D/CGPath.swift | 109 ----- .../SwiftSCAD/Shapes/2D/Text/BasicText.swift | 67 --- Sources/SwiftSCAD/Shapes/2D/Text/Text.swift | 405 ++---------------- .../Bezier/BezierPath+Visualization.swift | 2 +- Tests/Tests/2DTests.swift | 2 +- 5 files changed, 48 insertions(+), 537 deletions(-) delete mode 100644 Sources/SwiftSCAD/Shapes/2D/CGPath.swift delete mode 100644 Sources/SwiftSCAD/Shapes/2D/Text/BasicText.swift diff --git a/Sources/SwiftSCAD/Shapes/2D/CGPath.swift b/Sources/SwiftSCAD/Shapes/2D/CGPath.swift deleted file mode 100644 index 31c5e4e..0000000 --- a/Sources/SwiftSCAD/Shapes/2D/CGPath.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation - -#if canImport(QuartzCore) -import QuartzCore - -extension CGPath { - func normalizedPolygons(using fillRule: CGPathFillRule) -> (positive: Polygon, negatives: [Polygon]) { - let polygons = normalized(using: fillRule).polygons() - let first = polygons.first ?? Polygon([]) - return (first, Array(polygons[1...])) - } - - func polygons() -> [Polygon] { - var polygons: [Polygon] = [] - var currentPath: BezierPath2D? = nil - - applyWithBlock { pointer in - let element = pointer.pointee - - switch element.type { - case .moveToPoint: - if let currentPath { - polygons.append(Polygon(currentPath)) - } - currentPath = .init(startPoint: .init(element.points[0])) - - case .addLineToPoint: - if let path = currentPath { - currentPath = path.addingLine(to: .init(element.points[0])) - } - - case .addCurveToPoint: - let controlPoint1 = Vector2D(element.points[0]) - let controlPoint2 = Vector2D(element.points[1]) - let endPoint = Vector2D(element.points[2]) - if let path = currentPath { - currentPath = path.addingCubicCurve(controlPoint1: controlPoint1, controlPoint2: controlPoint2, end: endPoint) - } - - case .addQuadCurveToPoint: - let controlPoint = Vector2D(element.points[0]) - let endPoint = Vector2D(element.points[1]) - if let path = currentPath { - currentPath = path.addingQuadraticCurve(controlPoint: controlPoint, end: endPoint) - } - - case .closeSubpath: - break - @unknown default: - break - } - } - - if let currentPath { - polygons.append(Polygon(currentPath)) - } - return polygons - } -} - -extension Vector2D { - init(_ cgPoint: CGPoint) { - self.init(cgPoint.x, cgPoint.y) - } -} - -extension CGPath: Shape2D { - public var body: any Geometry2D { - EnvironmentReader { environment in - self.componentsSeparated(using: environment.cgPathFillRule).map { component in - let (positive, negatives) = component.normalizedPolygons(using: environment.cgPathFillRule) - return positive - .subtracting { - negatives - } - } - } - } - - static internal var fillRuleEnvironmentKey: Environment.ValueKey = .init(rawValue: "CGPath.FillRule") -} - -public extension Environment { - var cgPathFillRule: CGPathFillRule { - (self[CGPath.fillRuleEnvironmentKey] as? CGPathFillRule) ?? .evenOdd - } - - func usingCGPathFillRule(_ fillRule: CGPathFillRule) -> Environment { - setting(key: CGPath.fillRuleEnvironmentKey, value: fillRule) - } -} - -public extension Geometry2D { - func usingCGPathFillRule(_ fillRule: CGPathFillRule) -> any Geometry2D { - withEnvironment { environment in - environment.usingCGPathFillRule(fillRule) - } - } -} - -public extension Geometry3D { - func usingCGPathFillRule(_ fillRule: CGPathFillRule) -> any Geometry3D { - withEnvironment { environment in - environment.usingCGPathFillRule(fillRule) - } - } -} - -#endif diff --git a/Sources/SwiftSCAD/Shapes/2D/Text/BasicText.swift b/Sources/SwiftSCAD/Shapes/2D/Text/BasicText.swift deleted file mode 100644 index 7f8d599..0000000 --- a/Sources/SwiftSCAD/Shapes/2D/Text/BasicText.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -/// Basic OpenSCAD-generated text -public struct BasicText: LeafGeometry2D { - let text: String - let font: Font - let horizontalAlignment: HorizontalAlignment - let verticalAlignment: VerticalAlignment - let characterSpacingFactor: Double - - public init(_ text: String, font: Font, horizontalAlignment: HorizontalAlignment = .left, verticalAlignment: VerticalAlignment = .baseline, spacingFactor: Double = 1) { - self.text = text - self.font = font - self.horizontalAlignment = horizontalAlignment - self.verticalAlignment = verticalAlignment - self.characterSpacingFactor = spacingFactor - } - - public var invocation: Invocation { - .init(name: "text", parameters: [ - "text": text, - "size": font.size, - "font": font.fontString, - "halign": horizontalAlignment.rawValue, - "valign": verticalAlignment.rawValue, - "spacing": characterSpacingFactor - ]) - } - - public func boundary(in environment: Environment) -> Bounds { - // We don't know this. - .empty - } - - public struct Font { - public let name: String - public let size: Double - public let style: String? - - public init(name: String, size: Double, style: String? = nil) { - self.name = name - self.size = size - self.style = style - } - - fileprivate var fontString: String { - if let style = style { - return name + ":style=" + style - } else { - return name - } - } - } - - public enum HorizontalAlignment: String { - case left - case center - case right - } - - public enum VerticalAlignment: String { - case top - case center - case baseline - case bottom - } -} diff --git a/Sources/SwiftSCAD/Shapes/2D/Text/Text.swift b/Sources/SwiftSCAD/Shapes/2D/Text/Text.swift index 3f48679..7a983d6 100644 --- a/Sources/SwiftSCAD/Shapes/2D/Text/Text.swift +++ b/Sources/SwiftSCAD/Shapes/2D/Text/Text.swift @@ -1,379 +1,66 @@ import Foundation -#if canImport(AppKit) -import AppKit - -public struct Text: Shape2D { - private let text: AttributedString - private let layout: Layout - private let attributes: Attributes - - private init( - text: AttributedString, - layout: Layout, - font: Font?, - horizontalAlignment: NSTextAlignment, - verticalAlignment: Text.VerticalAlignment?, - customAttributes: AttributeContainer? - ) { +public struct Text: LeafGeometry2D { + let text: String + let font: Font + let horizontalAlignment: HorizontalAlignment + let verticalAlignment: VerticalAlignment + let characterSpacingFactor: Double + + public init(_ text: String, font: Font, horizontalAlignment: HorizontalAlignment = .left, verticalAlignment: VerticalAlignment = .baseline, spacingFactor: Double = 1) { self.text = text - self.layout = layout - let defaultVerticalAlignment: Text.VerticalAlignment = layout == .free ? .lastBaseline : .top - - self.attributes = .init( - font: font, - horizontalAlignment: horizontalAlignment, - verticalAlignment: verticalAlignment ?? defaultVerticalAlignment, - customAttributes: customAttributes - ) - } - - public init( - _ text: AttributedString, - layout: Layout = .free, - font: Font? = nil, - horizontalAlignment: NSTextAlignment = .left, - verticalAlignment: Text.VerticalAlignment? = nil - ) { - self.init( - text: text, - layout: layout, - font: font, - horizontalAlignment: horizontalAlignment, - verticalAlignment: verticalAlignment, - customAttributes: nil - ) - } - - public init( - markdown markdownString: String, - options: AttributedString.MarkdownParsingOptions = .init(), - layout: Layout = .free, - font: Font? = nil, - horizontalAlignment: NSTextAlignment = .left, - verticalAlignment: Text.VerticalAlignment? = nil, - attributes: AttributeContainer? = nil - ) throws { - try self.init( - text: .init(markdown: markdownString, options: options), - layout: layout, - font: font, horizontalAlignment: horizontalAlignment, - verticalAlignment: verticalAlignment, - customAttributes: attributes - ) - } - - public init( - _ string: String, - layout: Layout = .free, - font: Font? = nil, - horizontalAlignment: NSTextAlignment = .left, - verticalAlignment: Text.VerticalAlignment? = nil, - attributes: AttributeContainer? = nil - ) { - self.init( - text: .init(string), - layout: layout, - font: font, - horizontalAlignment: horizontalAlignment, - verticalAlignment: verticalAlignment, - customAttributes: attributes - ) - } - - public var body: any Geometry2D { - content() - } -} - -public extension Text { - var _debugLineFragments: any Geometry2D { - guard let (_, _, fragments, transform) = layoutData() else { - return Empty2D() - } - - let colors: [Color.Name] = [.red, .green, .blue] - - return Union { - for (index, fragment) in fragments.enumerated() { - let rectangle = Rectangle(.init(fragment.rect.width, fragment.rect.height)) - rectangle.subtracting { - rectangle.offset(amount: -0.01, style: .miter) - } - .translated(.init(fragment.rect.origin)) - .colored(colors[index % colors.count]) - - let usedRectangle = Rectangle(.init(fragment.usedRect.width, fragment.usedRect.height)) - usedRectangle.subtracting { - usedRectangle.offset(amount: -0.005, style: .miter) - } - .translated(.init(fragment.usedRect.origin)) - .colored(.black) - } - } - .transformed(transform) - } -} - -fileprivate extension Text { - var textContainerSize: CGSize { - switch layout { - case .constrained(let width, let height): - return .init(width: width, height: height ?? .infinity) - case .free: - return .init(width: 100000, height: CGFloat.infinity) - } - } - - var containerHeight: Double? { - if case .constrained(_, let height) = layout { - return height - } else { - return 0 - } - } - - func textOffset(contentHeight: CGFloat, firstBaselineOffset: CGFloat, lastBaselineOffset: CGFloat) -> Vector2D { - var offset = Vector2D.zero - let boxHeight = containerHeight ?? contentHeight - - switch attributes.verticalAlignment { - case .bottom: - offset.y = contentHeight - case .middle: - offset.y = (boxHeight + contentHeight) / 2.0 - case .firstBaseline: - offset.y = firstBaselineOffset - case .lastBaseline: - offset.y = contentHeight - lastBaselineOffset - default: - offset.y = boxHeight - } - - if case .free = layout { - switch attributes.horizontalAlignment { - case .center: - offset.x = -textContainerSize.width / 2 - case .right: - offset.x = -textContainerSize.width - default: - break - } - } - - return offset - } - - func layoutData() -> (NSLayoutManager, NSTextStorage, [NSLayoutManager.LineFragment], AffineTransform2D)? { - let textStorage = NSTextStorage(attributedString: .init(effectiveAttributedString)) - - let layoutManager = NSLayoutManager() - let textContainer = NSTextContainer(size: textContainerSize) - textContainer.lineFragmentPadding = 0 - layoutManager.addTextContainer(textContainer) - layoutManager.textStorage = textStorage - - let glyphRange = layoutManager.glyphRange(for: textContainer) - guard glyphRange.length > 0 else { - return nil - } - - let fragments = layoutManager.lineFragments(for: textContainer) - - let contentHeight = fragments.last?.rect.maxY ?? 0 - let lastBaselineOffset = layoutManager.typesetter.baselineOffset(in: layoutManager, glyphIndex: NSMaxRange(glyphRange)-1) - - let firstFragmentRect = layoutManager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil) - let firstBaselineOffset = firstFragmentRect.maxY - layoutManager.typesetter.baselineOffset(in: layoutManager, glyphIndex: 0) - - let offset = textOffset(contentHeight: contentHeight, firstBaselineOffset: firstBaselineOffset, lastBaselineOffset: lastBaselineOffset) - let transform = AffineTransform2D.scaling(y: -1).translated(offset) - - return (layoutManager, textStorage, fragments, transform) - } - - func content() -> any Geometry2D { - guard let (layoutManager, textStorage, fragments, transform) = layoutData() else { - return Empty2D() - } - - var verticalFlip = CGAffineTransform(scaleX: 1, y: -1) - - return Union { - for fragment in fragments { - for glyphIndex in fragment.glyphs { - let glyph = layoutManager.cgGlyph(at: glyphIndex) - let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) - - let font = textStorage.attribute(.font, at: characterIndex, effectiveRange: nil) as? NSFont - let color = textStorage.attribute(.foregroundColor, at: characterIndex, effectiveRange: nil) as? NSColor - - if let font, let path = CTFontCreatePathForGlyph(font, glyph, &verticalFlip) { - let geometry = path - .translated(.init(layoutManager.location(forGlyphAt: glyphIndex))) - .translated(.init(fragment.rect.origin)) - - if let color, let scadColor = Color(color) { - geometry - .colored(scadColor) - } else { - geometry - } - } - } - } - } - .transformed(transform) - .usingCGPathFillRule(.winding) - } - - var effectiveAttributedString: AttributedString { - text.mergingAttributes(attributes.stringAttributes, mergePolicy: .keepCurrent) - } -} - -public extension Text { - enum Layout: Equatable { - case free - case constrained (width: Double, height: Double? = nil) - } -} - -fileprivate extension NSLayoutManager { - struct LineFragment { - let glyphs: Range - let rect: CGRect - let usedRect: CGRect - } - - func lineFragments(for textContainer: NSTextContainer) -> [LineFragment] { - var fragments: [LineFragment] = [] - enumerateLineFragments(forGlyphRange: glyphRange(for: textContainer)) { rect, usedRect, textContainer, glyphRange, _ in - guard let range = Range(glyphRange) else { - assertionFailure("Invalid glyph range") - return - } - fragments.append(LineFragment(glyphs: range, rect: rect, usedRect: usedRect)) - } - return fragments - } -} - -fileprivate extension Color { - init?(_ nsColor: NSColor) { - guard let rgb = nsColor.usingColorSpace(.deviceRGB) else { - return nil - } - self = .components(red: rgb.redComponent, green: rgb.greenComponent, blue: rgb.blueComponent, alpha: rgb.alphaComponent) - } -} - -fileprivate extension Text.Attributes { - var stringAttributes: AttributeContainer { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = horizontalAlignment - - var attributes = AttributeContainer([ - .font: (font ?? .default).nsFont, - .paragraphStyle: paragraphStyle + self.font = font + self.horizontalAlignment = horizontalAlignment + self.verticalAlignment = verticalAlignment + self.characterSpacingFactor = spacingFactor + } + + public var invocation: Invocation { + .init(name: "text", parameters: [ + "text": text, + "size": font.size, + "font": font.fontString, + "halign": horizontalAlignment.rawValue, + "valign": verticalAlignment.rawValue, + "spacing": characterSpacingFactor ]) - - if let customAttributes { - attributes.merge(customAttributes, mergePolicy: .keepNew) - } - return attributes - } -} - - -extension Text { - fileprivate struct Attributes { - var font: Text.Font? - - var horizontalAlignment: NSTextAlignment - var verticalAlignment: VerticalAlignment - - var customAttributes: AttributeContainer? } - public enum VerticalAlignment: Equatable { - case top - case middle - case bottom - - case firstBaseline - case lastBaseline + public func boundary(in environment: Environment) -> Bounds { + // We don't know this. + .empty } public struct Font { - fileprivate let nsFont: NSFont + public let name: String + public let size: Double + public let style: String? - fileprivate static var `default`: Font { - Font(nsFont: NSFont(name: "Helvetica", size: 12) ?? .systemFont(ofSize: 12)) - } - - public static func named(_ name: String, size: CGFloat) -> Font? { - guard let font = NSFont(name: name, size: size) else { - assertionFailure("Could not find font named \(name)") - return nil - } - return .init(nsFont: font) - } - - public static func inFamily(_ family: String, style: String, size: CGFloat) -> Font? { - let descriptor = NSFontDescriptor() - .withFamily(family) - .withFace(style) - guard let font = NSFont(descriptor: descriptor, size: size) else { - assertionFailure("Could not find font with family \(family) and style \(style)") - return nil - } - return .init(nsFont: font) + public init(name: String, size: Double, style: String? = nil) { + self.name = name + self.size = size + self.style = style } - public static func inFamily(_ family: String, weight: NSFont.Weight, size: CGFloat) -> Font? { - guard let font = NSFont.font(family: family, weight: weight, size: size) else { - assertionFailure("Could not find font with family \(family) and weight \(weight)") - return nil + fileprivate var fontString: String { + if let style = style { + return name + ":style=" + style + } else { + return name } - return .init(nsFont: font) } } -} - -fileprivate extension NSFont { - static func font(family: String, weight targetWeight: NSFont.Weight, size: CGFloat) -> NSFont? { - fontsInFamily(family, size: size)? - .compactMap { (font: NSFont) -> (font: NSFont, distance: CGFloat)? in - guard let weight = font.weight, !font.fontDescriptor.symbolicTraits.contains(.italic) else { - return nil - } - return (font, abs(targetWeight - weight)) - } - .sorted(by: { $0.distance < $1.distance }) - .first?.font + public enum HorizontalAlignment: String { + case left + case center + case right } - var weight: NSFont.Weight? { - guard let traits = fontDescriptor.object(forKey: .traits) as? [NSFontDescriptor.TraitKey: Any] else { - return nil - } - guard let weight = traits[.weight] as? CGFloat else { - return nil - } - return .init(weight) - - } - - static func fontsInFamily(_ family: String, size: CGFloat) -> [NSFont]? { - let names = NSFontManager.shared.availableMembers(ofFontFamily: family)?.compactMap { $0[0] as? String } - guard let names else { - return nil - } - - return names.compactMap { NSFont(name: $0, size: size) } + public enum VerticalAlignment: String { + case top + case center + case baseline + case bottom } } - -#endif diff --git a/Sources/SwiftSCAD/Values/Bezier/BezierPath+Visualization.swift b/Sources/SwiftSCAD/Values/Bezier/BezierPath+Visualization.swift index d379000..22a2cbc 100644 --- a/Sources/SwiftSCAD/Values/Bezier/BezierPath+Visualization.swift +++ b/Sources/SwiftSCAD/Values/Bezier/BezierPath+Visualization.swift @@ -12,7 +12,7 @@ extension BezierPath { RoundedBox([4, 2, 0.1], center: .all, axis: .z, cornerRadius: 1) .colored(.white) .adding { - BasicText(text, font: .init(name: "Helvetica", size: 1), horizontalAlignment: .center, verticalAlignment: .center) + Text(text, font: .init(name: "Helvetica", size: 1), horizontalAlignment: .center, verticalAlignment: .center) .extruded(height: 0.01) .translated(z: 0.1) .colored(.black) diff --git a/Tests/Tests/2DTests.swift b/Tests/Tests/2DTests.swift index 56dec88..30eb866 100644 --- a/Tests/Tests/2DTests.swift +++ b/Tests/Tests/2DTests.swift @@ -36,7 +36,7 @@ final class Geometry2DTests: XCTestCase { .rotated(45°) .translated(x: -3) - BasicText("SwiftSCAD", font: .init(name: "Helvetica", size: 10), horizontalAlignment: .left, verticalAlignment: .bottom) + Text("SwiftSCAD", font: .init(name: "Helvetica", size: 10), horizontalAlignment: .left, verticalAlignment: .bottom) .offset(amount: 0.4, style: .miter) .translated(y: 5) .sheared(.y, angle: 20°)