├── .gitattributes ├── README └── exampleScreenshot.png ├── Ashton.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ ├── xcbaselines │ │ └── C8A9CE081F6D13700095C6AE.xcbaseline │ │ │ ├── 83517260-04C7-486F-80E3-F8A7D5D6A18E.plist │ │ │ ├── 9E034BEE-3BE2-4D0F-89B0-A2CD31F34A27.plist │ │ │ ├── B1527039-5615-4D1E-A3CC-73418C65BC08.plist │ │ │ ├── B88B5BBE-20FB-4700-9E87-97DC62F4AD55.plist │ │ │ ├── C9247703-08BE-48CB-9137-68AB29A0C507.plist │ │ │ ├── 2A3F4C04-9917-463F-BA7D-918A4EB23308.plist │ │ │ ├── 183F476C-0236-4F09-8113-C48A97F3079D.plist │ │ │ ├── C782AF6D-D7AF-4242-99F1-4980D64B4409.plist │ │ │ └── Info.plist │ └── xcschemes │ │ └── Ashton.xcscheme ├── AshtonTests_Info.plist └── Ashton_Info.plist ├── Example ├── Example.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── project.pbxproj └── Example │ ├── Example.entitlements │ ├── AppDelegate.swift │ ├── Info.plist │ └── Assets.xcassets │ └── AppIcon.appiconset │ └── Contents.json ├── Tests ├── AshtonBenchmark │ ├── AshtonHTMLWriter.h │ ├── AshtonMarkdownWriter.h │ ├── AshtonMarkdownReader.h │ ├── AshtonHTMLReader.h │ ├── AshtonConverter.h │ ├── AshtonUIKit.h │ ├── AshtonAppKit.h │ ├── AshtonCoreText.h │ ├── AshtonUtils.h │ ├── AshtonMarkdownReader.m │ ├── NSAttributedString+Ashton.h │ ├── Info.plist │ ├── AshtonIntermediate.h │ ├── AshtonIntermediate.m │ ├── NSAttributedString+Ashton.m │ ├── AshtonUtils.m │ ├── AshtonMarkdownWriter.m │ ├── AshtonCoreText.m │ ├── AshtonUIKit.m │ ├── AshtonHTMLWriter.m │ ├── AshtonHTMLReader.m │ └── AshtonAppKit.m ├── Bridging.h ├── TestFiles │ ├── TextStyles.rtf │ └── RTFText.rtf ├── AshtonXMLParserTests.swift ├── AshtonBenchmarkTests.swift └── IteratorParsingTests.swift ├── Sources └── Ashton │ ├── Resources │ └── PrivacyInfo.xcprivacy │ ├── Cache.swift │ ├── Mappings.swift │ ├── CrossPlatformCompatibility.swift │ ├── Ashton.swift │ ├── FontBuilder.swift │ ├── AshtonHTMLReader.swift │ ├── Iterator+Parsing.swift │ ├── AshtonHTMLWriter.swift │ └── AshtonXMLParser.swift ├── .buildkite └── pipeline.yml ├── Package.swift ├── .gitignore ├── Package@swift-5.9.swift ├── LICENSE ├── CHANGELOG.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | Tests/AshtonBenchmark/* linguist-vendored 2 | -------------------------------------------------------------------------------- /README/exampleScreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdeasOnCanvas/Ashton/HEAD/README/exampleScreenshot.png -------------------------------------------------------------------------------- /Ashton.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonHTMLWriter.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface AshtonHTMLWriter : NSObject 4 | 5 | + (instancetype)sharedInstance; 6 | 7 | - (NSString *)HTMLStringFromAttributedString:(NSAttributedString *)input; 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonMarkdownWriter.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface AshtonMarkdownWriter : NSObject 4 | 5 | + (instancetype)sharedInstance; 6 | 7 | - (NSString *)markdownStringFromAttributedString:(NSAttributedString *)input; 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonMarkdownReader.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface AshtonMarkdownReader : NSObject 4 | 5 | + (instancetype)sharedInstance; 6 | 7 | - (NSAttributedString *)attributedStringFromMarkdownString:(NSString *)htmlString; 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonHTMLReader.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface AshtonHTMLReader : NSObject < NSXMLParserDelegate > 4 | 5 | + (instancetype)HTMLReader; 6 | + (void)clearStylesCache; 7 | - (NSAttributedString *)attributedStringFromHTMLString:(NSString *)htmlString; 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/Bridging.h: -------------------------------------------------------------------------------- 1 | // 2 | // AshtonBenchmark.h 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 16.09.17. 6 | // Copyright © 2017 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | #ifndef AshtonBenchmark_h 10 | #define AshtonBenchmark_h 11 | 12 | #import "NSAttributedString+Ashton.h" 13 | 14 | #endif /* AshtonBenchmark_h */ 15 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonConverter.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | @protocol AshtonConverter 5 | 6 | - (NSAttributedString *)intermediateRepresentationWithTargetRepresentation:(NSAttributedString *)input; 7 | - (NSAttributedString *)targetRepresentationWithIntermediateRepresentation:(NSAttributedString *)input; 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /Example/Example/Example.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonUIKit.h: -------------------------------------------------------------------------------- 1 | #import "AshtonConverter.h" 2 | 3 | @interface AshtonUIKit : NSObject < AshtonConverter > 4 | 5 | + (instancetype)sharedInstance; 6 | 7 | - (NSAttributedString *)intermediateRepresentationWithTargetRepresentation:(NSAttributedString *)input; 8 | - (NSAttributedString *)targetRepresentationWithIntermediateRepresentation:(NSAttributedString *)input; 9 | 10 | @end 11 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonAppKit.h: -------------------------------------------------------------------------------- 1 | #import "AshtonConverter.h" 2 | 3 | @interface AshtonAppKit : NSObject < AshtonConverter > 4 | 5 | + (instancetype)sharedInstance; 6 | 7 | - (NSAttributedString *)intermediateRepresentationWithTargetRepresentation:(NSAttributedString *)input; 8 | - (NSAttributedString *)targetRepresentationWithIntermediateRepresentation:(NSAttributedString *)input; 9 | 10 | @end 11 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonCoreText.h: -------------------------------------------------------------------------------- 1 | #import "AshtonConverter.h" 2 | 3 | @interface AshtonCoreText : NSObject < AshtonConverter > 4 | 5 | + (instancetype)sharedInstance; 6 | 7 | - (NSAttributedString *)intermediateRepresentationWithTargetRepresentation:(NSAttributedString *)input; 8 | - (NSAttributedString *)targetRepresentationWithIntermediateRepresentation:(NSAttributedString *)input; 9 | 10 | @end 11 | -------------------------------------------------------------------------------- /Sources/Ashton/Resources/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyCollectedDataTypes 8 | 9 | NSPrivacyTrackingDomains 10 | 11 | NSPrivacyAccessedAPITypes 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | env: 2 | DEVELOPER_DIR: "/Applications/Xcode.app/Contents/Developer" 3 | PATH: "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" 4 | LANG: "en_US.UTF-8" 5 | LC_ALL: "en_US.UTF-8" 6 | 7 | steps: 8 | - command: "#!/bin/bash\n\nbundle install --path ~/gems\n\nbundle exec fastlane ios test" 9 | label: ":iphone: iOS Tests" 10 | 11 | - command: "#!/bin/bash\n\nbundle install --path ~/gems\n\nbundle exec fastlane mac test" 12 | label: ":desktop_computer: macOS Tests" 13 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonUtils.h: -------------------------------------------------------------------------------- 1 | #import 2 | #if TARGET_OS_IPHONE 3 | #import 4 | #else 5 | #import 6 | #endif 7 | 8 | @interface AshtonUtils : NSObject 9 | 10 | + (id)CTFontRefWithFamilyName:(NSString *)familyName postScriptName:(NSString *)postScriptName size:(CGFloat)pointSize boldTrait:(BOOL)isBold italicTrait:(BOOL)isItalic features:(NSArray *)features; 11 | 12 | + (void)clearFontsCache; 13 | + (NSArray *)arrayForCGColor:(CGColorRef)color; 14 | 15 | 16 | @end 17 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonMarkdownReader.m: -------------------------------------------------------------------------------- 1 | #import "AshtonMarkdownReader.h" 2 | 3 | @implementation AshtonMarkdownReader 4 | 5 | + (instancetype)sharedInstance { 6 | static dispatch_once_t onceToken; 7 | static AshtonMarkdownReader *sharedInstance; 8 | dispatch_once(&onceToken, ^{ 9 | sharedInstance = [[AshtonMarkdownReader alloc] init]; 10 | }); 11 | return sharedInstance; 12 | } 13 | 14 | - (NSAttributedString *)attributedStringFromMarkdownString:(NSString *)htmlString { 15 | return nil; 16 | } 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Ashton", 6 | platforms: [.iOS("13.4"), .macOS(.v10_15)], 7 | products: [.library(name: "Ashton", targets: ["Ashton"])], 8 | targets: [ 9 | .target( 10 | name: "Ashton", 11 | dependencies: [], 12 | path: "Sources"), 13 | .testTarget( 14 | name: "AshtonTests", 15 | dependencies: ["Ashton"], 16 | path: "Tests", 17 | exclude: ["TestFiles", "AshtonBenchmark", "Bridging.h", "AshtonBenchmarkTests.swift"]), 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | .swiftpm/ 20 | .DS_Store 21 | 22 | ## Other 23 | *.moved-aside 24 | *.xccheckout 25 | *.xcscmblueprint 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace -------------------------------------------------------------------------------- /Sources/Ashton/Cache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Dictionary based cache, used for FontStyles and StyleAttributes during reading of HTML. 5 | public final class Cache { 6 | 7 | private var elements: [Key: Value] 8 | 9 | // MARK: - Properties 10 | 11 | var isEmpty: Bool { self.elements.isEmpty } 12 | 13 | // MARK: - Lifecycle 14 | 15 | init(_ elements: [Key: Value] = [:]) { 16 | self.elements = elements 17 | } 18 | 19 | // MARK: - Cache 20 | 21 | subscript(key: Key) -> Value? { 22 | get { self.elements[key] } 23 | set { self.elements[key] = newValue } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Package@swift-5.9.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Ashton", 6 | platforms: [.iOS("13.4"), .macOS(.v10_15), .visionOS(.v1)], 7 | products: [.library(name: "Ashton", targets: ["Ashton"])], 8 | targets: [ 9 | .target( 10 | name: "Ashton", 11 | dependencies: [], 12 | path: "Sources", 13 | resources: [.copy("Ashton/Resources/PrivacyInfo.xcprivacy")]), 14 | .testTarget( 15 | name: "AshtonTests", 16 | dependencies: ["Ashton"], 17 | path: "Tests", 18 | exclude: ["TestFiles", "AshtonBenchmark", "Bridging.h", "AshtonBenchmarkTests.swift"]), 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/83517260-04C7-486F-80E3-F8A7D5D6A18E.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | AshtonTests 8 | 9 | testSampleRTFTextDecodingPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.014848 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/9E034BEE-3BE2-4D0F-89B0-A2CD31F34A27.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | AshtonTests 8 | 9 | testSampleRTFTextDecodingPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.00442 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/B1527039-5615-4D1E-A3CC-73418C65BC08.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | AshtonTests 8 | 9 | testAttributeDecodingPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.070457 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/B88B5BBE-20FB-4700-9E87-97DC62F4AD55.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | AshtonTests 8 | 9 | testSampleRTFTextDecodingPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.00359 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/C9247703-08BE-48CB-9137-68AB29A0C507.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | AshtonTests 8 | 9 | testSampleRTFTextDecodingPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.002234 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/2A3F4C04-9917-463F-BA7D-918A4EB23308.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | AshtonTests 8 | 9 | testSampleRTFTextDecodingPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.0041206 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/NSAttributedString+Ashton.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface NSAttributedString (Ashton) 4 | 5 | // See http://objcolumnist.com/2011/11/03/keeping-the-static-analyzer-happy-prefixed-initializers/ for an explanation of 6 | // NS_RETURNS_RETAINED and __attribute__((ns_consumes_self)) 7 | 8 | // Attributed String with UIKit or AppKit Attributes 9 | - (NSString *)mn_HTMLRepresentation; 10 | - (instancetype)initWithHTMLString:(NSString *)htmlString NS_RETURNS_RETAINED __attribute__((ns_consumes_self)); 11 | 12 | // Attributed String with CT Attributes 13 | - (NSString *)mn_HTMLRepresentationFromCoreTextAttributes; 14 | - (instancetype)mn_initWithCoreTextAttributesFromHTMLString:(NSString *)htmlString NS_RETURNS_RETAINED __attribute__((ns_consumes_self)); 15 | 16 | @end 17 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/AshtonTests_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSPrincipalClass 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/Ashton_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 2.0.6 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSPrincipalClass 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Example/Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Example 4 | // 5 | // Created by Michael Schwarz on 18.12.17. 6 | // Copyright © 2017 IdeasOnCanvas GmbH. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Ashton 11 | 12 | @NSApplicationMain 13 | class AppDelegate: NSObject, NSApplicationDelegate { 14 | 15 | @IBOutlet weak var baseTextView: NSTextView! 16 | @IBOutlet weak var window: NSWindow! 17 | @IBOutlet weak var roundTripTextView: NSTextView! 18 | @IBOutlet weak var htmlTextView: NSTextView! 19 | 20 | @IBAction func executeRoundTrip(_ sender: Any) { 21 | guard let attributedString = self.baseTextView.textStorage else { return } 22 | 23 | let html = Ashton.encode(attributedString) 24 | self.htmlTextView.string = html 25 | let roundTrip = Ashton.decode(html) 26 | self.roundTripTextView.textStorage?.setAttributedString(roundTrip) 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /Sources/Ashton/Mappings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mappings.swift 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 19.12.17. 6 | // Copyright © 2017 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | #if os(iOS) || (compiler(>=5.9) && os(visionOS)) 10 | import UIKit 11 | #elseif os(macOS) 12 | import AppKit 13 | #endif 14 | import Foundation 15 | 16 | 17 | // Defines mappings between HTML attributes and AttributedString keys 18 | struct Mappings { 19 | 20 | struct UnderlineStyle { 21 | static let encode: [NSUnderlineStyle.RawValue: String] = [ 22 | NSUnderlineStyle.single.rawValue: "single", 23 | NSUnderlineStyle.double.rawValue: "double", 24 | NSUnderlineStyle.thick.rawValue: "thick" 25 | ] 26 | } 27 | 28 | struct TextAlignment { 29 | static let encode: [NSTextAlignment: String] = [ 30 | .left: "left", 31 | .center: "center", 32 | .right: "right", 33 | .justified: "justify" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 IdeasOnCanvas GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/183F476C-0236-4F09-8113-C48A97F3079D.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | AshtonTests 8 | 9 | testSampleRTFTextDecodingPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.00909 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | AshtonXMLParserTests 21 | 22 | testXMLParsingPerformance() 23 | 24 | com.apple.XCTPerformanceMetric_WallClockTime 25 | 26 | baselineAverage 27 | 0.00309 28 | baselineIntegrationDisplayName 29 | Local Baseline 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /Tests/TestFiles/TextStyles.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf1561\cocoasubrtf200 2 | {\fonttbl\f0\fswiss\fcharset0 Helvetica;\f1\froman\fcharset0 Times-Roman;} 3 | {\colortbl;\red255\green255\blue255;\red255\green0\blue0;\red128\green0\blue128;\red251\green0\blue255; 4 | } 5 | {\*\expandedcolortbl;;\csgenericrgb\c100000\c0\c0;\csgenericrgb\c50196\c0\c50196;\csgenericrgb\c98431\c0\c100000; 6 | } 7 | \paperw11900\paperh16840\margl1440\margr1440\vieww10800\viewh8400\viewkind0 8 | \deftab720 9 | \pard\pardeftab720\partightenfactor0 10 | 11 | \f0\b\fs32 \cf0 \ 12 | Some bold text. 13 | \i\b0 Some italic text. 14 | \f1\i0\fs24 \ 15 | \pard\pardeftab720\partightenfactor0 16 | 17 | \f0\fs32 \cf0 \ul \ulc0 Single Underlined.\uldb Double Underlined.\ulc2 Red Underlined 18 | \f1\fs24 \ulnone \ 19 | \pard\pardeftab720\partightenfactor0 20 | 21 | \f0\fs32 \cf0 \strike \strikec0 Single Strikethrough.\strike0\striked0 \strike \strikec0 Double Strikethrough. \strikec2 Red Strikethrough\ 22 | \uldb \ulc3 \striked1 \strikec2 Red Double Strikethrough Underline 23 | \f1\fs24 \ulnone \strike0\striked0 \ 24 | \pard\pardeftab720\partightenfactor0 25 | 26 | \f0\fs32 \cf4 Purple text.\ 27 | } -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | Copyright © 2017 IdeasOnCanvas GmbH. All rights reserved. 27 | NSMainNibFile 28 | MainMenu 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "size" : "16x16", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "size" : "16x16", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "size" : "32x32", 16 | "scale" : "1x" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "size" : "32x32", 21 | "scale" : "2x" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "size" : "128x128", 26 | "scale" : "1x" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "size" : "128x128", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "size" : "256x256", 36 | "scale" : "1x" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "size" : "256x256", 41 | "scale" : "2x" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "size" : "512x512", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "size" : "512x512", 51 | "scale" : "2x" 52 | } 53 | ], 54 | "info" : { 55 | "version" : 1, 56 | "author" : "xcode" 57 | } 58 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Ashton Changelog 2 | 3 | > - [Bugfix | Enhancement | Feature | Internal] High-Level Description of your Change (#PR Number) 4 | 5 | ## Unassigned 6 | 7 | ## 2.3.1 8 | 9 | - [Enhancement] Add Privacy Manifest 10 | 11 | ## 2.3 12 | 13 | Add support for vision OS. 14 | 15 | ## 2.2.1 16 | 17 | - [Bugfix] Fix implicit newline handling after `

` tag 18 | 19 | ## 2.2 20 | 21 | - [Enhancement] Parse trailing whitespace 22 | 23 | ## 2.1 24 | 25 | - [Enhancement] Allow to use Ashton in multithreaded environment 26 | - [Chore] Add additional explicitly dynamic library target 27 | 28 | ## 2.0.8 29 | - [Chore] Update ruby gems 30 | 31 | ## 2.0.7 32 | - [Chore] Update project settings (Xcode 12) 33 | - [Chore] Update ruby gems 34 | 35 | ## 2.0.6 36 | - [Bugfix] Fixes skipping single style attributes after parsing text-decoration attributes (#30) 37 | 38 | ## 2.0.5 39 | - [Enhancement] Add parsing completion handler for reporting unknown fonts (#23) 40 | 41 | ## 2.0.4 42 | - [Bugfix] Fix a out-of-bounds exception when parsing certain asian characters (#27) 43 | 44 | ## 2.0.3 45 | - [Enhancement] Parse `` and `` tags (#23) 46 | - [Bugfix] Fix writing and reading of multiple text-decoration attribute values (#22) 47 | - [Internal] Handle malformed xml more gracefully - early return instead of assert (#21) 48 | - [Internal] Add Changelog 49 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonIntermediate.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | extern NSString * const AshtonAttrColor; 4 | extern NSString * const AshtonAttrBackgroundColor; 5 | extern NSString * const AshtonAttrFont; 6 | extern NSString * const AshtonAttrLink; 7 | extern NSString * const AshtonAttrParagraph; 8 | extern NSString * const AshtonAttrStrikethrough; 9 | extern NSString * const AshtonAttrStrikethroughColor; 10 | extern NSString * const AshtonAttrUnderline; 11 | extern NSString * const AshtonAttrUnderlineColor; 12 | extern NSString * const AshtonAttrVerticalAlign; 13 | extern NSString * const AshtonAttrBaselineOffset; 14 | 15 | extern NSString * const AshtonFontAttrTraitBold; 16 | extern NSString * const AshtonFontAttrTraitItalic; 17 | extern NSString * const AshtonFontAttrFeatures; 18 | extern NSString * const AshtonFontAttrPointSize; 19 | extern NSString * const AshtonFontAttrFamilyName; 20 | extern NSString * const AshtonFontAttrPostScriptName; 21 | 22 | extern NSString * const AshtonParagraphAttrTextAlignment; 23 | extern NSString * const AshtonParagraphAttrTextAlignmentStyleLeft; 24 | extern NSString * const AshtonParagraphAttrTextAlignmentStyleRight; 25 | extern NSString * const AshtonParagraphAttrTextAlignmentStyleCenter; 26 | extern NSString * const AshtonParagraphAttrTextAlignmentStyleJustified; 27 | 28 | extern NSString * const AshtonStrikethroughStyleSingle; 29 | extern NSString * const AshtonStrikethroughStyleThick; 30 | extern NSString * const AshtonStrikethroughStyleDouble; 31 | 32 | extern NSString * const AshtonUnderlineStyleSingle; 33 | extern NSString * const AshtonUnderlineStyleThick; 34 | extern NSString * const AshtonUnderlineStyleDouble; 35 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/C782AF6D-D7AF-4242-99F1-4980D64B4409.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | AshtonTests 8 | 9 | testAttributeDecodingPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.10413 15 | baselineIntegrationDisplayName 16 | 23.09.2017, 15:35:34 17 | 18 | 19 | testParagraphDecodingPerformance() 20 | 21 | com.apple.XCTPerformanceMetric_WallClockTime 22 | 23 | baselineAverage 24 | 0.062785 25 | baselineIntegrationDisplayName 26 | 18.09.2017, 22:05:35 27 | 28 | 29 | testParagraphEncodingPerformance() 30 | 31 | com.apple.XCTPerformanceMetric_WallClockTime 32 | 33 | baselineAverage 34 | 0.065795 35 | baselineIntegrationDisplayName 36 | 18.09.2017, 22:09:48 37 | 38 | 39 | testParagraphSpacingPerformance() 40 | 41 | com.apple.XCTPerformanceMetric_WallClockTime 42 | 43 | baselineAverage 44 | 0.59032 45 | baselineIntegrationDisplayName 46 | 18.09.2017, 21:56:59 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonIntermediate.m: -------------------------------------------------------------------------------- 1 | #import "AshtonIntermediate.h" 2 | 3 | NSString * const AshtonAttrColor = @"color"; 4 | NSString * const AshtonAttrBackgroundColor = @"backgroundColor"; 5 | NSString * const AshtonAttrFont = @"font"; 6 | NSString * const AshtonAttrLink = @"link"; 7 | NSString * const AshtonAttrParagraph = @"paragraph"; 8 | NSString * const AshtonAttrStrikethrough = @"strikethrough"; 9 | NSString * const AshtonAttrStrikethroughColor = @"strikethroughColor"; 10 | NSString * const AshtonAttrUnderline = @"underline"; 11 | NSString * const AshtonAttrUnderlineColor = @"underlineColor"; 12 | NSString * const AshtonAttrVerticalAlign = @"verticalAlign"; 13 | NSString * const AshtonAttrBaselineOffset = @"baselineOffset"; 14 | 15 | NSString * const AshtonFontAttrTraitBold = @"traitBold"; 16 | NSString * const AshtonFontAttrTraitItalic = @"traitItalic"; 17 | NSString * const AshtonFontAttrFeatures = @"features"; 18 | NSString * const AshtonFontAttrPointSize = @"pointSize"; 19 | NSString * const AshtonFontAttrFamilyName = @"familyName"; 20 | NSString * const AshtonFontAttrPostScriptName = @"postScriptName"; 21 | 22 | NSString * const AshtonParagraphAttrTextAlignment = @"textAlignment"; 23 | NSString * const AshtonParagraphAttrTextAlignmentStyleLeft = @"left"; 24 | NSString * const AshtonParagraphAttrTextAlignmentStyleRight = @"right"; 25 | NSString * const AshtonParagraphAttrTextAlignmentStyleCenter = @"center"; 26 | NSString * const AshtonParagraphAttrTextAlignmentStyleJustified = @"justified"; 27 | 28 | NSString * const AshtonStrikethroughStyleSingle = @"single"; 29 | NSString * const AshtonStrikethroughStyleThick = @"thick"; 30 | NSString * const AshtonStrikethroughStyleDouble = @"double"; 31 | 32 | NSString * const AshtonUnderlineStyleSingle = @"single"; 33 | NSString * const AshtonUnderlineStyleThick = @"thick"; 34 | NSString * const AshtonUnderlineStyleDouble = @"double"; 35 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/NSAttributedString+Ashton.m: -------------------------------------------------------------------------------- 1 | #import "NSAttributedString+Ashton.h" 2 | #import "AshtonCoreText.h" 3 | #import "AshtonHTMLWriter.h" 4 | #import "AshtonHTMLReader.h" 5 | #if TARGET_OS_IPHONE 6 | #import "AshtonUIKit.h" 7 | #else 8 | #import "AshtonAppKit.h" 9 | #endif 10 | 11 | @implementation NSAttributedString (Ashton) 12 | 13 | - (NSString *)mn_HTMLRepresentation 14 | { 15 | #if TARGET_OS_IPHONE 16 | NSAttributedString *attString = [[[AshtonUIKit alloc] init] intermediateRepresentationWithTargetRepresentation:self]; 17 | #else 18 | NSAttributedString *attString = [[[AshtonAppKit alloc] init] intermediateRepresentationWithTargetRepresentation:self]; 19 | #endif 20 | return [[[AshtonHTMLWriter alloc] init] HTMLStringFromAttributedString:attString]; 21 | } 22 | 23 | - (instancetype)initWithHTMLString:(NSString *)htmlString 24 | { 25 | NSAttributedString *attributedString = [[[AshtonHTMLReader alloc] init] attributedStringFromHTMLString:htmlString]; 26 | #if TARGET_OS_IPHONE 27 | attributedString = [[[AshtonUIKit alloc] init] targetRepresentationWithIntermediateRepresentation:attributedString]; 28 | #else 29 | attributedString = [[[AshtonAppKit alloc] init] targetRepresentationWithIntermediateRepresentation:attributedString]; 30 | #endif 31 | return [self initWithAttributedString:attributedString]; 32 | } 33 | 34 | 35 | - (NSString *)mn_HTMLRepresentationFromCoreTextAttributes 36 | { 37 | NSAttributedString *attString = [[AshtonCoreText sharedInstance] intermediateRepresentationWithTargetRepresentation:self]; 38 | return [[AshtonHTMLWriter sharedInstance] HTMLStringFromAttributedString:attString]; 39 | } 40 | 41 | - (instancetype)mn_initWithCoreTextAttributesFromHTMLString:(NSString *)htmlString 42 | { 43 | NSAttributedString *attString = [[AshtonHTMLReader HTMLReader] attributedStringFromHTMLString:htmlString]; 44 | attString = [[AshtonCoreText sharedInstance] targetRepresentationWithIntermediateRepresentation:attString]; 45 | return [self initWithAttributedString:attString]; 46 | } 47 | 48 | @end 49 | -------------------------------------------------------------------------------- /Sources/Ashton/CrossPlatformCompatibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CrossPlatformCompatibility.swift 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 11.12.17. 6 | // Copyright © 2017 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | #if os(iOS) || (compiler(>=5.9) && os(visionOS)) 10 | import UIKit 11 | typealias Font = UIFont 12 | typealias FontDescriptor = UIFontDescriptor 13 | typealias FontDescriptorSymbolicTraits = UIFontDescriptor.SymbolicTraits 14 | typealias Color = UIColor 15 | 16 | extension UIFont { 17 | class var cpFamilyNames: [String] { return UIFont.familyNames } 18 | var cpFamilyName: String { return self.familyName } 19 | 20 | class func cpFontNames(forFamilyName familyName: String) -> [String] { 21 | return UIFont.fontNames(forFamilyName: familyName) 22 | } 23 | } 24 | 25 | extension UIFontDescriptor { 26 | var cpPostscriptName: String { return self.postscriptName } 27 | } 28 | 29 | extension NSAttributedString.Key { 30 | static let superscript = NSAttributedString.Key(rawValue: "NSSuperScript") 31 | } 32 | 33 | extension FontDescriptor.FeatureKey { 34 | static let selectorIdentifier = FontDescriptor.FeatureKey("CTFeatureSelectorIdentifier") 35 | static let cpTypeIdentifier = FontDescriptor.FeatureKey("CTFeatureTypeIdentifier") 36 | } 37 | 38 | #elseif os(macOS) 39 | import AppKit 40 | typealias Font = NSFont 41 | typealias FontDescriptor = NSFontDescriptor 42 | typealias FontDescriptorSymbolicTraits = NSFontDescriptor.SymbolicTraits 43 | typealias Color = NSColor 44 | 45 | extension NSFont { 46 | class var cpFamilyNames: [String] { return NSFontManager.shared.availableFontFamilies } 47 | var cpFamilyName: String { return self.familyName ?? "" } 48 | 49 | class func cpFontNames(forFamilyName familyName: String) -> [String] { 50 | let fontManager = NSFontManager.shared 51 | let availableMembers = fontManager.availableMembers(ofFontFamily: familyName) 52 | return availableMembers?.compactMap { member in 53 | let memberArray = member as Array 54 | return memberArray.first as? String 55 | } ?? [] 56 | } 57 | } 58 | 59 | extension NSFontDescriptor { 60 | var cpPostscriptName: String { return self.postscriptName ?? "" } 61 | } 62 | 63 | extension FontDescriptor.FeatureKey { 64 | static let cpTypeIdentifier = FontDescriptor.FeatureKey("CTFeatureTypeIdentifier") 65 | } 66 | #endif 67 | -------------------------------------------------------------------------------- /Sources/Ashton/Ashton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ashton.swift 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 16.09.17. 6 | // Copyright © 2017 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Transforms NSAttributedString <--> HTML 12 | @objc 13 | public final class Ashton: NSObject { 14 | 15 | public typealias HTML = String 16 | 17 | internal static let reader = AshtonHTMLReader() 18 | internal static let writer = AshtonHTMLWriter() 19 | 20 | /// Encodes an NSAttributedString into a HTML representation 21 | /// 22 | /// - Parameter attributedString: The NSAttributedString to encode 23 | /// - Returns: The HTML representation 24 | /// - Note: Convenience interface which isn't threadsafe. If you use Ashton from multiple threads use AshtonHTMLReader/AshtonHTMLWriter directly 25 | @objc 26 | public static func encode(_ attributedString: NSAttributedString) -> HTML { 27 | return Ashton.writer.encode(attributedString) 28 | } 29 | 30 | /// Decodes a HTML representation into an NSAttributedString 31 | /// 32 | /// - Parameter html: The HTML representation to encode 33 | /// - Parameter defaultAttributes: Attributes which are used if no attribute is specified in the HTML 34 | /// - Returns: The decoded NSAttributedString 35 | /// - Note: Convenience interface which isn't threadsafe. If you use Ashton from multiple threads use AshtonHTMLReader/AshtonHTMLWriter directly 36 | @objc 37 | public static func decode(_ html: HTML, defaultAttributes: [NSAttributedString.Key: Any] = [:]) -> NSAttributedString { 38 | self.decode(html, defaultAttributes: defaultAttributes) { _ in } 39 | } 40 | 41 | /// Decodes a HTML representation into an NSAttributedString 42 | /// 43 | /// - Parameter html: The HTML representation to encode. 44 | /// - Parameter defaultAttributes: Attributes which are used if no attribute is specified in the HTML. 45 | /// - Parameter completionHandler: Called when the receiver did finish parsing. A result type containing parsing information gets passed in. This is called synchronously right before returning. 46 | /// - Returns: The decoded NSAttributedString. 47 | public static func decode(_ html: HTML, defaultAttributes: [NSAttributedString.Key: Any] = [:], completionHandler: AshtonHTMLReadCompletionHandler) -> NSAttributedString { 48 | return Ashton.reader.decode(html, defaultAttributes: defaultAttributes, completionHandler: completionHandler) 49 | } 50 | 51 | /// Clears decoding caches (e.g. already parsed and converted html style attribute strings are cached) 52 | @objc 53 | public static func clearCaches() { 54 | Ashton.reader.clearCaches() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Ashton/FontBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontBuilder.swift 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 20.01.18. 6 | // Copyright © 2018 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreGraphics 11 | import CoreText 12 | 13 | 14 | /// Creates a NS/UIFont 15 | final class FontBuilder { 16 | 17 | typealias FontCache = Cache 18 | 19 | // MARK: - Properties 20 | 21 | var fontCache: FontCache 22 | var fontName: String? { return self.postScriptName ?? self.familyName } 23 | var familyName: String? 24 | var postScriptName: String? 25 | var isBold: Bool = false 26 | var isItalic: Bool = false 27 | var pointSize: CGFloat? 28 | var fontFeatures: [[String: Any]]? 29 | var cacheKey: String { 30 | guard let familyName = self.fontName else { return "" } 31 | guard let pointSize = self.pointSize else { return "" } 32 | 33 | return "\(familyName)\(pointSize)\(self.isBold)\(self.isItalic)\(self.fontFeatures?.description ?? "")" 34 | } 35 | 36 | // MARK: - Lifecycle 37 | 38 | init(fontCache: FontCache? = nil) { 39 | self.fontCache = fontCache ?? .init() 40 | } 41 | 42 | // MARK: - FontBuilder 43 | 44 | func configure(with font: Font) { 45 | self.familyName = font.familyName 46 | self.pointSize = font.pointSize 47 | } 48 | 49 | func makeFont() -> Font? { 50 | guard let fontName = self.fontName else { return nil } 51 | guard let pointSize = self.pointSize else { return nil } 52 | 53 | let cacheKey = self.cacheKey 54 | if let cachedFont = self.fontCache[cacheKey] { 55 | return cachedFont 56 | } 57 | 58 | var attributes: [FontDescriptor.AttributeName: Any] = [FontDescriptor.AttributeName.name: fontName] 59 | if let fontFeatures = self.fontFeatures { 60 | attributes[.featureSettings] = fontFeatures 61 | } 62 | 63 | var fontDescriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary) 64 | 65 | if self.postScriptName == nil { 66 | var symbolicTraits = CTFontSymbolicTraits() 67 | #if os(iOS) || (compiler(>=5.9) && os(visionOS)) 68 | if self.isBold { symbolicTraits.insert(.boldTrait) } 69 | if self.isItalic { symbolicTraits.insert(.italicTrait) } 70 | #elseif os(macOS) 71 | if self.isBold { symbolicTraits.insert(.boldTrait) } 72 | if self.isItalic { symbolicTraits.insert(.italicTrait) } 73 | #endif 74 | fontDescriptor = CTFontDescriptorCreateCopyWithSymbolicTraits(fontDescriptor, symbolicTraits, symbolicTraits) ?? fontDescriptor 75 | } 76 | 77 | let font = CTFontCreateWithFontDescriptor(fontDescriptor, pointSize, nil) as Font 78 | #if os(macOS) 79 | // on macOS we have to do this conversion CTFont -> NSFont, otherwise we have wrong glyph spacing for some (arabic) fonts when rendering on device 80 | let descriptor = font.fontDescriptor 81 | let convertedFont = Font(descriptor: descriptor, size: descriptor.pointSize) 82 | self.fontCache[cacheKey] = convertedFont 83 | return convertedFont 84 | #else 85 | self.fontCache[cacheKey] = font 86 | return font 87 | #endif 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcschemes/Ashton.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 63 | 64 | 70 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonUtils.m: -------------------------------------------------------------------------------- 1 | #import "AshtonUtils.h" 2 | #import 3 | 4 | @implementation AshtonUtils 5 | 6 | + (id)CTFontRefWithFamilyName:(NSString *)familyName postScriptName:(NSString *)postScriptName size:(CGFloat)pointSize boldTrait:(BOOL)isBold italicTrait:(BOOL)isItalic features:(NSArray *)features { 7 | 8 | NSMutableDictionary *cache = [self fontsCache]; 9 | NSMutableDictionary *descriptorAttributes = [NSMutableDictionary dictionaryWithCapacity:3]; 10 | descriptorAttributes[(id)kCTFontSizeAttribute] = @(pointSize); 11 | if (familyName) descriptorAttributes[(id)kCTFontNameAttribute] = familyName; 12 | if (postScriptName) descriptorAttributes[(id)kCTFontNameAttribute] = postScriptName; 13 | 14 | if (features) { 15 | NSMutableArray *fontFeatures = [NSMutableArray array]; 16 | for (NSArray *feature in features) { 17 | [fontFeatures addObject:@{(id)kCTFontFeatureTypeIdentifierKey:feature[0], (id)kCTFontFeatureSelectorIdentifierKey:feature[1]}]; 18 | } 19 | descriptorAttributes[(id)kCTFontFeatureSettingsAttribute] = fontFeatures; 20 | } 21 | id font; 22 | id cached_font = cache[descriptorAttributes]; 23 | 24 | if (cached_font) { 25 | font = cached_font; 26 | } else { 27 | CTFontDescriptorRef descriptor = CTFontDescriptorCreateWithAttributes((__bridge CFDictionaryRef)(descriptorAttributes)); 28 | 29 | font = CFBridgingRelease(CTFontCreateWithFontDescriptor(descriptor, pointSize, NULL)); 30 | CFRelease(descriptor); 31 | 32 | cache[descriptorAttributes] = font; 33 | } 34 | 35 | // We ignore symbolic traits when a postScriptName is given, because the postScriptName already encodes bold/italic and if we 36 | // specify it again as a trait we get different fonts (e.g. Helvetica-Oblique becomes Helvetica-LightOblique) 37 | CTFontSymbolicTraits symbolicTraits = 0; // using CTFontGetSymbolicTraits also makes CTFontCreateCopyWithSymbolicTraits fail 38 | if (!postScriptName && isBold) symbolicTraits = symbolicTraits | kCTFontTraitBold; 39 | if (!postScriptName && isItalic) symbolicTraits = symbolicTraits | kCTFontTraitItalic; 40 | if (symbolicTraits != 0) { 41 | // Unfortunately CTFontCreateCopyWithSymbolicTraits returns NULL when there are no symbolicTraits (== 0) 42 | // Is there a better way to detect "no" symbolic traits? 43 | CTFontRef newFont = CTFontCreateCopyWithSymbolicTraits((__bridge CTFontRef)font, 0.0, NULL, symbolicTraits, symbolicTraits); 44 | // And even worse, if a font is defined to be "only" bold (like Arial Rounded MT Bold is) then 45 | // CTFontCreateCopyWithSymbolicTraits also returns NULL 46 | if (newFont != NULL) { 47 | font = CFBridgingRelease(newFont); 48 | } 49 | } 50 | #ifdef __MAC_OS_X_VERSION_MIN_REQUIRED 51 | NSFontDescriptor *fontDescriptor = [font fontDescriptor]; 52 | font = [NSFont fontWithDescriptor:fontDescriptor size:fontDescriptor.pointSize]; 53 | #endif 54 | return font; 55 | } 56 | 57 | + (NSMutableDictionary *)fontsCache 58 | { 59 | static NSMutableDictionary *cache = nil; 60 | if (!cache) { 61 | cache = [NSMutableDictionary dictionary]; 62 | } 63 | return cache; 64 | } 65 | 66 | + (void)clearFontsCache 67 | { 68 | [[self fontsCache] removeAllObjects]; 69 | } 70 | 71 | + (NSArray *)arrayForCGColor:(CGColorRef)color { 72 | CGFloat red, green, blue; 73 | CGFloat alpha = CGColorGetAlpha(color); 74 | const CGFloat *components = CGColorGetComponents(color); 75 | if (CGColorGetNumberOfComponents(color) == 2) { 76 | red = green = blue = components[0]; 77 | } else if (CGColorGetNumberOfComponents(color) == 4) { 78 | red = components[0]; 79 | green = components[1]; 80 | blue = components[2]; 81 | } else { 82 | red = green = blue = 0; 83 | } 84 | return @[ @(red), @(green), @(blue), @(alpha) ]; 85 | } 86 | 87 | @end 88 | -------------------------------------------------------------------------------- /Ashton.xcodeproj/xcshareddata/xcbaselines/C8A9CE081F6D13700095C6AE.xcbaseline/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runDestinationsByUUID 6 | 7 | 183F476C-0236-4F09-8113-C48A97F3079D 8 | 9 | localComputer 10 | 11 | busSpeedInMHz 12 | 100 13 | cpuCount 14 | 1 15 | cpuKind 16 | Intel Core i5 17 | cpuSpeedInMHz 18 | 2400 19 | logicalCPUCoresPerPackage 20 | 4 21 | modelCode 22 | MacBookPro11,1 23 | physicalCPUCoresPerPackage 24 | 2 25 | platformIdentifier 26 | com.apple.platform.macosx 27 | 28 | targetArchitecture 29 | x86_64 30 | 31 | 9E034BEE-3BE2-4D0F-89B0-A2CD31F34A27 32 | 33 | localComputer 34 | 35 | busSpeedInMHz 36 | 100 37 | cpuCount 38 | 1 39 | cpuKind 40 | Intel Core i5 41 | cpuSpeedInMHz 42 | 2700 43 | logicalCPUCoresPerPackage 44 | 4 45 | modelCode 46 | MacBookPro12,1 47 | physicalCPUCoresPerPackage 48 | 2 49 | platformIdentifier 50 | com.apple.platform.macosx 51 | 52 | targetArchitecture 53 | x86_64 54 | targetDevice 55 | 56 | modelCode 57 | iPhone8,1 58 | platformIdentifier 59 | com.apple.platform.iphonesimulator 60 | 61 | 62 | B88B5BBE-20FB-4700-9E87-97DC62F4AD55 63 | 64 | localComputer 65 | 66 | busSpeedInMHz 67 | 100 68 | cpuCount 69 | 1 70 | cpuKind 71 | Intel Core i5 72 | cpuSpeedInMHz 73 | 2700 74 | logicalCPUCoresPerPackage 75 | 4 76 | modelCode 77 | MacBookPro12,1 78 | physicalCPUCoresPerPackage 79 | 2 80 | platformIdentifier 81 | com.apple.platform.macosx 82 | 83 | targetArchitecture 84 | x86_64 85 | 86 | C782AF6D-D7AF-4242-99F1-4980D64B4409 87 | 88 | localComputer 89 | 90 | busSpeedInMHz 91 | 100 92 | cpuCount 93 | 1 94 | cpuKind 95 | Intel Core i5 96 | cpuSpeedInMHz 97 | 2400 98 | logicalCPUCoresPerPackage 99 | 4 100 | modelCode 101 | MacBookPro11,1 102 | physicalCPUCoresPerPackage 103 | 2 104 | platformIdentifier 105 | com.apple.platform.macosx 106 | 107 | targetArchitecture 108 | x86_64 109 | targetDevice 110 | 111 | modelCode 112 | iPhone10,5 113 | platformIdentifier 114 | com.apple.platform.iphonesimulator 115 | 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /Tests/TestFiles/RTFText.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf1504\cocoasubrtf830 2 | {\fonttbl\f0\fswiss\fcharset0 Helvetica;\f1\froman\fcharset0 Times-Roman;\f2\fnil\fcharset128 HiraKakuPro-W3; 3 | \f3\fswiss\fcharset0 ArialMT;\f4\froman\fcharset0 TimesNewRomanPSMT;\f5\fnil\fcharset0 AmericanTypewriter; 4 | \f6\fnil\fcharset0 AmericanTypewriter-Light;} 5 | {\colortbl;\red255\green255\blue255;\red255\green0\blue0;\red128\green0\blue128;\red251\green0\blue255; 6 | } 7 | {\*\expandedcolortbl;;\csgenericrgb\c100000\c0\c0;\csgenericrgb\c50196\c0\c50196;\csgenericrgb\c98431\c0\c100000; 8 | } 9 | \paperw11900\paperh16840\margl1440\margr1440\vieww10800\viewh8400\viewkind0 10 | \deftab720 11 | \pard\pardeftab720\partightenfactor0 12 | 13 | \f0\b\fs32 \cf0 Some bold text. 14 | \i\b0 Some italic text. 15 | \f1\i0\fs24 \ 16 | \pard\pardeftab720\partightenfactor0 17 | 18 | \f0\fs32 \cf0 \ul \ulc0 Single Underlined.\uldb Double Underlined.\ulc2 Red Underlined 19 | \f1\fs24 \ulnone \ 20 | \pard\pardeftab720\partightenfactor0 21 | 22 | \f0\fs32 \cf0 \strike \strikec0 Single Strikethrough.\strike0\striked0 \strike \strikec0 Double Strikethrough. \strikec2 Red Strikethrough\ 23 | \uldb \ulc3 \striked1 \strikec2 Red Double Strikethrough Underline 24 | \f1\fs24 \ulnone \strike0\striked0 \ 25 | \pard\pardeftab720\partightenfactor0 26 | 27 | \f0\fs32 \cf4 Purple text.\ 28 | \cf0 Left Justified.\ 29 | \pard\pardeftab720\qc\partightenfactor0 30 | \cf0 Ce 31 | \b nter 32 | \b0 ed.\ 33 | \pard\pardeftab720\qr\partightenfactor0 34 | \cf0 Ri 35 | \b g{\field{\*\fldinst{HYPERLINK "http://google.com"}}{\fldrslt h}} 36 | \b0 t Justified.\ 37 | \pard\pardeftab720\partightenfactor0 38 | \cf0 Some Japanese text now:\ 39 | \pard\pardeftab720\partightenfactor0 40 | 41 | \f2 \cf0 \'82\'b1\'82\'cc\'83\'74\'83\'40\'83\'43\'83\'8b\'82\'f0\'8a\'4a\'82\'a2\'82\'bd\'8c\'e3\'82\'dc\'82\'bd\'82\'cd\'95\'db\'91\'b6\'82\'b5\'82\'bd\'8c\'e3\'82\'c5\'81\'41\'95\'ca\'82\'cc\'83\'41\'83\'76\'83\'8a\'83\'50\'81\'5b\'83\'56\'83\'87\'83\'93\'82\'c9\'82\'e6\'82\'c1\'82\'c4\'95\'cf\'8d\'58\'82\'b3\'82\'ea\'82\'c4\'82\'a2\'82\'dc\'82\'b7\'81\'42\ 42 | 43 | \f3 44 | \f2 \ 45 | \pard\pardeftab720\partightenfactor0 46 | 47 | \f0 \cf0 Composed chars (304B 309A):\ 48 | \pard\pardeftab720\partightenfactor0 49 | 50 | \f2 \cf0 \uc0\u12363 \u12442 \ 51 | \pard\pardeftab720\partightenfactor0 52 | {\field{\*\fldinst{HYPERLINK "http://google.com/?a=%22b'"}}{\fldrslt 53 | \f0 \cf0 Link to Apple}}{\field{\*\fldinst{HYPERLINK "http://google.com"}}{\fldrslt 54 | \f0 \ 55 | }}\pard\pardeftab720\partightenfactor0 56 | 57 | \f0 \cf0 Size 16 58 | \fs28 Size 14 59 | \i\b BoldItalic\ul Underlined\ 60 | \pard\pardeftab720\partightenfactor0 61 | 62 | \f1\i0\b0\fs32 \cf0 \ulnone Times 63 | \f4 Times New Roman\ 64 | 65 | \f5 American Typewriter 66 | \f6 Light{\field{\*\fldinst{HYPERLINK "http://google.com"}}{\fldrslt 67 | \f1\fs24 \ 68 | }}\pard\pardeftab720\partightenfactor0 69 | 70 | \f0\b \cf0 \ 71 | \pard\pardeftab720\partightenfactor0 72 | 73 | \b0 \cf0 00\ 74 | \ 75 | \pard\pardeftab720\sl280\sa280\qj\partightenfactor0 76 | 77 | \f3\fs22 \cf0 Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.\ 78 | Baseline\up2 Rais\up4 ed\dn2 Lower\dn4 ed\ 79 | \up0 \super2 Super\super script\nosupersub Normal\sub Sub\sub2 script\ 80 | \up2 \nosupersub Super\sub script\sub2 Raised\up0 Baseline\dn2 Lowered\sub3 Sub\sub4 script} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ashton 2 | 3 | [![SPM compatible](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat)](https://swift.org/package-manager/) 4 | [![Carthage Compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) 5 | ![Platforms iOS, macOS, visionOS](https://img.shields.io/badge/Platform-iOS%20|%20macOS|%20visionOS-blue.svg "Platforms iOS, macOS, visionOS") 6 | ![Language Swift](https://img.shields.io/badge/Language-Swift%205-green.svg "Swift 5.0") 7 | [![License](https://img.shields.io/badge/license-MIT-green.svg?style=flat)](LICENSE.md) 8 | [![Twitter: @_mschwarz_](https://img.shields.io/badge/Twitter-@__mschwarz__-red.svg?style=flat)](https://twitter.com/_mschwarz_) 9 | 10 | Ashton (AttributedStringHTMLTransformation) is an iOS, macOS, and visionOS library for fast conversion of NSAttributedStrings into HTML, and back. Ashton is battle-tested and used in [MindNode](https://mindnode.com), for persisting formatted strings. 11 | 12 | ## 2.0 Release 13 | 14 | The latest release is a complete rewrite in Swift focusing on improved performance and functional backwards compatibility to Ashton 1.x. The new codebase has a comprehensive test suite with a test coverage of > 90% and additional tests against the legacy 1.0 output. 15 | 16 | Find out more about the launch of Ashton 2.0 in our [Blog Post](https://mindnode.com/news/2019-04-10-a-snappier-mindnode-text-persistence). 17 | 18 | ## Supported Attributes 19 | 20 | The following `NSAttributedString.Key` attributes are supported, when converting to `HTML`: 21 | - [x] .backgroundColor (persisted as RGBA) 22 | - [x] .foregroundColor (persisted as RGBA) 23 | - [x] .underlineStyle (single, double, thick) 24 | - [x] .underlineColor (persisted as RGBA) 25 | - [x] .strikethroughColor (persisted as RGBA) 26 | - [x] .strikethroughStyle (single, double, thick) 27 | - [x] .font 28 | - [x] .paragraphStyle (text alignment) 29 | - [x] .baselineOffset 30 | - [x] NSSuperScript 31 | - [x] .link 32 | 33 | ## Supported HTML Tags & Attributes 34 | 35 | As Ashton supports only tags which are necessary to persist the attributes mentioned above, not all HTML tags are supported when converting `HTML` --> `AttributedString`. Basically, Ashton converts an AttributedString into a concatenation of `span`, `p` and `a` tags with style attributes. 36 | 37 | Supported HTML Tags: 38 | - [x] span 39 | - [x] p 40 | - [x] a 41 | - [x] em 42 | - [x] strong 43 | 44 | The following style attribute keys are supported: 45 | - [x] background-color 46 | - [x] color 47 | - [x] text-decoration 48 | - [x] font 49 | - [x] text-align 50 | - [x] vertical-align 51 | - [x] Additional custom attributes (-cocoa-strikethrough-color, -cocoa-underline-color, -cocoa-baseline-offset, -cocoa-vertical-align, -cocoa-font-postscriptname, -cocoa-underline, -cocoa-strikethrough, -cocoa-fontFeatures) 52 | 53 | Colors have to be formatted as rgba like `rgba(0, 0, 0, 1.000000)`. 54 | 55 | ## Integration 56 | 57 | ### Carthage 58 | 59 | Add this line to your Cartfile. 60 | ``` 61 | github "IdeasOnCanvas/Ashton" 62 | ``` 63 | 64 | ### Integration with the Swift Package Manager 65 | 66 | The Swift Package Manager is a dependency manager integrated with the Swift build system. To learn how to use the Swift Package Manager for your project, please read the [official documentation](https://github.com/apple/swift-package-manager/blob/main/Documentation/Usage.md). 67 | To add Ashton as a dependency, you have to add it to the `dependencies` of your `Package.swift` file and refer to that dependency in your `target`. 68 | 69 | ```swift 70 | // swift-tools-version:5.1 71 | import PackageDescription 72 | let package = Package( 73 | name: "", 74 | dependencies: [ 75 | .package(url: "https://github.com/IdeasOnCanvas/Ashton/", .upToNextMajor(from: "2.0.0")) 76 | ], 77 | targets: [ 78 | .target( 79 | name: "", 80 | dependencies: ["Ashton"]), 81 | ] 82 | ) 83 | ``` 84 | 85 | After adding the dependency, you can fetch the library with: 86 | 87 | ```bash 88 | $ swift package resolve 89 | ``` 90 | 91 | ## Usage 92 | 93 | ### Encode HTML 94 | 95 | ```swift 96 | let htmlString = Ashton.encode(attributedString) 97 | ``` 98 | 99 | ### Decode NSAttributedString 100 | 101 | ```swift 102 | let attributedString = Ashton.decode(htmlString) 103 | ``` 104 | 105 | ## Example App 106 | 107 | An example app can be found in the `/Example` directory. It can be used to test `NSAttributedString` -> HTML -> `NSAttributedString` roundtrips and also to extract the HTML representation of an `NSAttributedString. 108 | 109 | ![](README/exampleScreenshot.png) 110 | 111 | 112 | ## Credits 113 | 114 | Ashton is brought to you by [IdeasOnCanvas GmbH](https://ideasoncanvas.com), the creator of [MindNode for iOS, macOS & watchOS](https://mindnode.com). 115 | -------------------------------------------------------------------------------- /Sources/Ashton/AshtonHTMLReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AshtonHTMLReader.swift 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 17.09.17. 6 | // Copyright © 2017 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreGraphics 11 | 12 | public typealias AshtonHTMLReadCompletionHandler = (_ readResult: AshtonHTMLReadResult) -> Void 13 | 14 | @objc 15 | public final class AshtonHTMLReadResult: NSObject { 16 | public let unknownFonts: Set 17 | 18 | public init(unknownFonts: [String]) { 19 | // We accept an `Array` on the call site (because arrays are a bit more convenient) 20 | // but convert to `Set` here for deduplication. 21 | self.unknownFonts = Set(unknownFonts) 22 | } 23 | } 24 | 25 | public final class AshtonHTMLReader: NSObject { 26 | 27 | private var attributesStack: [[NSAttributedString.Key: Any]] = [] 28 | private var output: NSMutableAttributedString! 29 | private var parsedTags: [AshtonXMLParser.Tag] = [] 30 | private var unknownFonts: [String] = [] 31 | private var appendNewlineBeforeNextContent = false 32 | 33 | // MARK: - Properties 34 | 35 | private(set) var fontBuilderCache: FontBuilder.FontCache 36 | private(set) var xmlParser: AshtonXMLParser 37 | 38 | // MARK: - Lifecycle 39 | 40 | public override convenience init() { 41 | self.init(fontBuilderCache: .init(), styleCache: .init()) 42 | } 43 | 44 | init(fontBuilderCache: FontBuilder.FontCache, styleCache: AshtonXMLParser.StyleAttributesCache) { 45 | let fontBuilderCache = fontBuilderCache 46 | self.fontBuilderCache = fontBuilderCache 47 | self.xmlParser = .init(styleAttributesCache: styleCache, fontBuilderCache: fontBuilderCache) 48 | } 49 | 50 | // MARK: - 51 | 52 | public func decode(_ html: Ashton.HTML, defaultAttributes: [NSAttributedString.Key: Any] = [:], completionHandler: AshtonHTMLReadCompletionHandler) -> NSAttributedString { 53 | self.output = NSMutableAttributedString() 54 | self.appendNewlineBeforeNextContent = false 55 | self.parsedTags = [] 56 | self.attributesStack = [defaultAttributes] 57 | self.unknownFonts = [] 58 | 59 | self.xmlParser.delegate = self 60 | self.xmlParser.parse(string: html) 61 | // Since `.parse` is a synchronous call, we can simply run the completion handler here. 62 | // This allows us to stick with the default, implicit `@noescape` behavior. 63 | completionHandler(AshtonHTMLReadResult(unknownFonts: self.unknownFonts)) 64 | 65 | return self.output 66 | } 67 | 68 | func clearCaches() { 69 | self.fontBuilderCache = .init() 70 | self.xmlParser = .init(styleAttributesCache: .init(), fontBuilderCache: self.fontBuilderCache) 71 | } 72 | } 73 | 74 | // MARK: - AshtonXMLParserDelegate 75 | 76 | extension AshtonHTMLReader: AshtonXMLParserDelegate { 77 | 78 | func didParseContent(_ parser: AshtonXMLParser, string: String) { 79 | self.appendToOutput(string) 80 | } 81 | 82 | func didOpenTag(_ parser: AshtonXMLParser, name: AshtonXMLParser.Tag, attributes: [NSAttributedString.Key : Any]?) { 83 | if self.appendNewlineBeforeNextContent { 84 | self.appendToOutput("\n") 85 | self.appendNewlineBeforeNextContent = false 86 | self.attributesStack.removeLast() 87 | } 88 | var attributes = attributes ?? [:] 89 | let currentAttributes = self.attributesStack.last ?? [:] 90 | 91 | if let derivedFontBuilder = self.makeDerivedFontBuilder(forTag: name) { 92 | attributes[.font] = derivedFontBuilder.makeFont() 93 | } 94 | 95 | attributes.merge(currentAttributes, uniquingKeysWith: { (current, _) in current }) 96 | 97 | self.attributesStack.append(attributes) 98 | self.parsedTags.append(name) 99 | } 100 | 101 | func didCloseTag(_ parser: AshtonXMLParser) { 102 | guard self.attributesStack.isEmpty == false, self.parsedTags.isEmpty == false else { 103 | return 104 | } 105 | 106 | if self.parsedTags.removeLast() == .p { 107 | self.appendNewlineBeforeNextContent = true 108 | } else { 109 | self.attributesStack.removeLast() 110 | } 111 | } 112 | 113 | func didEncounterUnknownFont(_ parser: AshtonXMLParser, fontName: String) { 114 | self.unknownFonts.append(fontName) 115 | } 116 | } 117 | 118 | // MARK: - Private 119 | 120 | private extension AshtonHTMLReader { 121 | 122 | func appendToOutput(_ string: String) { 123 | if let attributes = self.attributesStack.last, attributes.isEmpty == false { 124 | self.output.append(NSAttributedString(string: string, attributes: attributes)) 125 | } else { 126 | self.output.append(NSAttributedString(string: string)) 127 | } 128 | } 129 | 130 | func makeDerivedFontBuilder(forTag tag: AshtonXMLParser.Tag) -> FontBuilder? { 131 | guard tag == .strong || tag == .em else { return nil } 132 | guard let currentFont = self.attributesStack.last?[.font] as? Font else { return nil } 133 | 134 | let fontBuilder = FontBuilder(fontCache: self.fontBuilderCache) 135 | fontBuilder.configure(with: currentFont) 136 | fontBuilder.isBold = (tag == .strong) 137 | fontBuilder.isItalic = (tag == .em) 138 | return fontBuilder 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Tests/AshtonXMLParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AshtonXMLParserTests.swift 3 | // AshtonTests 4 | // 5 | // Created by Michael Schwarz on 16.01.18. 6 | // Copyright © 2018 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Ashton 11 | 12 | 13 | final class AshtonXMLParserTests: XCTestCase { 14 | 15 | func testEscapeSubstitution() { 16 | let sampleString = "hello & world" 17 | XCTAssertEqual(self.parseString(sampleString), "hello & world") 18 | 19 | let sampleString2 = "'hello' <> "world"" 20 | XCTAssertEqual(self.parseString(sampleString2), "'hello' <> \"world\"") 21 | 22 | let sampleString3 = "&lfds;" 23 | XCTAssertEqual(self.parseString(sampleString3), "&lfds;") 24 | 25 | let sampleString4 = "&lfdsfasdfasdf" 26 | XCTAssertEqual(self.parseString(sampleString4), "&lfdsfasdfasdf") 27 | } 28 | 29 | func testTagParsing() { 30 | let sampleString = "

hello & world not this

" 31 | let delegate = DummyParserDelegate() 32 | let parser = AshtonXMLParser() 33 | parser.delegate = delegate 34 | parser.parse(string: sampleString) 35 | XCTAssertEqual(delegate.closedTags, 3) 36 | XCTAssertEqual(delegate.openedTags.map { $0.name }, [.p, .span, .ignored]) 37 | } 38 | 39 | func testParsingWithoutSemicolonTerminatedAttributes() { 40 | let result = self.parseString("\n

These are the notes of Subtopic 2\n

\n\n") 41 | XCTAssertEqual(result, "\nThese are the notes of Subtopic 2\n\n\n") 42 | 43 | let result2 = self.parseString("\n

Node with note and URL\n

\n\n") 44 | XCTAssertEqual(result2, "\nNode with note and URL\n\n\n") 45 | } 46 | 47 | func testSingleStyleAttributesParsing() { 48 | let sampleString = "Test" 49 | 50 | let delegate = DummyParserDelegate() 51 | let parser = AshtonXMLParser() 52 | parser.delegate = delegate 53 | parser.parse(string: sampleString) 54 | XCTAssertEqual(delegate.openedTags.count, 1) 55 | 56 | let attributes = delegate.openedTags.first!.attributes! 57 | XCTAssertEqual(attributes.values.count, 1) 58 | XCTAssertTrue(attributes[.backgroundColor] is Color) 59 | } 60 | 61 | func testMultipleStyleAttributesParsing() { 62 | let styleString = "\\UF016Hello World" 63 | let delegate = DummyParserDelegate() 64 | let parser = AshtonXMLParser() 65 | parser.delegate = delegate 66 | parser.parse(string: styleString) 67 | XCTAssertEqual(delegate.openedTags.count, 2) 68 | 69 | let attributes = delegate.openedTags.first!.attributes! 70 | XCTAssertEqual(attributes.count, 2) 71 | } 72 | 73 | func testHrefParsing() { 74 | let sampleString = "
h" 75 | let delegate = DummyParserDelegate() 76 | let parser = AshtonXMLParser() 77 | parser.delegate = delegate 78 | parser.parse(string: sampleString) 79 | XCTAssertEqual(delegate.openedTags.count, 1) 80 | 81 | let attributes = delegate.openedTags.first!.attributes! 82 | XCTAssertEqual(attributes.count, 3) 83 | XCTAssertEqual(attributes[.link] as! URL, URL(string: "http://google.com")!) 84 | } 85 | 86 | /* Deactivated for SPM 87 | func testXMLParsingPerformance() { 88 | let rtfURL = Bundle(for: AshtonTests.self).url(forResource: "RTFText", withExtension: "rtf")! 89 | let attributedString = try! NSAttributedString(url: rtfURL, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) 90 | let sampleHTML = Ashton.encode(attributedString) + "" 91 | let delegate = DummyParserDelegate() 92 | let parser = AshtonXMLParser() 93 | self.measure { 94 | parser.delegate = delegate 95 | parser.parse(string: sampleHTML) 96 | } 97 | }*/ 98 | } 99 | 100 | // MARK: - Private 101 | 102 | private extension AshtonXMLParserTests { 103 | 104 | final class DummyParserDelegate: AshtonXMLParserDelegate { 105 | var openedTags: [(name: AshtonXMLParser.Tag, attributes: [NSAttributedString.Key: Any]?)] = [] 106 | var content: String = "" 107 | var closedTags = 0 108 | 109 | func didOpenTag(_ parser: AshtonXMLParser, name: AshtonXMLParser.Tag, attributes: [NSAttributedString.Key: Any]?) { 110 | self.openedTags.append((name, attributes)) 111 | } 112 | 113 | func didCloseTag(_ parser: AshtonXMLParser) { 114 | closedTags += 1 115 | } 116 | 117 | func didParseContent(_ parser: AshtonXMLParser, string: String) { 118 | self.content.append(string) 119 | } 120 | } 121 | 122 | func parseString(_ string: String) -> String { 123 | let parser = AshtonXMLParser() 124 | let dummyDelegate = DummyParserDelegate() 125 | parser.delegate = dummyDelegate 126 | parser.parse(string: string) 127 | return dummyDelegate.content 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonMarkdownWriter.m: -------------------------------------------------------------------------------- 1 | #import "AshtonMarkdownWriter.h" 2 | #import "AshtonIntermediate.h" 3 | #if TARGET_OS_IPHONE 4 | #import "AshtonUIKit.h" 5 | #else 6 | #import "AshtonAppKit.h" 7 | #endif 8 | 9 | static void writeMarkdownFragment(NSAttributedString *input, NSString *inputString, NSRange range, NSMutableString *output) { 10 | __block BOOL outputIsBold = NO; 11 | __block BOOL outputIsItalic = NO; 12 | __block BOOL outputIsStrikethrough = NO; 13 | __block BOOL outputIsLink = NO; 14 | __block NSString *outputLink; // current link 15 | __block NSString *previousSuffix = nil; 16 | 17 | __block BOOL didParseWord = NO; 18 | void(^parseBlock)(NSString *, NSRange, NSRange, BOOL *) = ^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { 19 | NSDictionary *attrs = [input attributesAtIndex:substringRange.location effectiveRange:NULL]; 20 | BOOL isBold = [attrs[AshtonAttrFont][AshtonFontAttrTraitBold] boolValue]; 21 | BOOL isItalic = [attrs[AshtonAttrFont][AshtonFontAttrTraitItalic] boolValue]; 22 | BOOL isStrikethrough = (attrs[AshtonAttrStrikethrough] != nil); 23 | BOOL isLink = (attrs[AshtonAttrLink] != nil); 24 | 25 | NSUInteger prefixLocation = enclosingRange.location; 26 | NSUInteger prefixLength = substringRange.location - enclosingRange.location; 27 | NSUInteger suffixLocation = substringRange.location + substringRange.length; 28 | NSUInteger suffixLength = (enclosingRange.location + enclosingRange.length) - suffixLocation; 29 | NSString *suffix = nil, *prefix = nil; 30 | if (suffixLength > 0) suffix = [inputString substringWithRange:NSMakeRange(suffixLocation, suffixLength)]; 31 | if (prefixLength > 0) prefix = [inputString substringWithRange:NSMakeRange(prefixLocation, prefixLength)]; 32 | 33 | if (outputIsLink && !isLink) { 34 | if (outputIsBold) [output appendString:@"**"]; 35 | if (outputIsItalic) [output appendString:@"*"]; 36 | if (outputIsStrikethrough) [output appendString:@"~~"]; 37 | [output appendFormat:@"](%@)", outputLink]; 38 | if (outputIsBold && isBold) [output appendString:@"**"]; 39 | if (outputIsItalic && isItalic) [output appendString:@"*"]; 40 | if (outputIsStrikethrough && isStrikethrough) [output appendString:@"~~"]; 41 | outputLink = nil; 42 | } else { 43 | if (outputIsBold && !isBold) [output appendString:@"**"]; 44 | if (outputIsItalic && !isItalic) [output appendString:@"*"]; 45 | if (outputIsStrikethrough && !isStrikethrough) [output appendString:@"~~"]; 46 | } 47 | 48 | if (!outputIsLink && isLink) { 49 | outputLink = attrs[AshtonAttrLink]; 50 | if (outputIsStrikethrough) [output appendString:@"~~"]; 51 | if (outputIsBold) [output appendString:@"**"]; 52 | if (outputIsItalic) [output appendString:@"*"]; 53 | if (previousSuffix) [output appendString:previousSuffix]; 54 | if (prefix) [output appendString:prefix]; 55 | [output appendString:@"["]; 56 | if (isStrikethrough) [output appendString:@"~~"]; 57 | if (isBold) [output appendString:@"**"]; 58 | if (isItalic) [output appendString:@"*"]; 59 | } else { 60 | if (previousSuffix) [output appendString:previousSuffix]; 61 | if (prefix) [output appendString:prefix]; 62 | if (!outputIsStrikethrough && isStrikethrough) [output appendString:@"~~"]; 63 | if (!outputIsBold && isBold) [output appendString:@"**"]; 64 | if (!outputIsItalic && isItalic) [output appendString:@"*"]; 65 | } 66 | [output appendString:substring]; 67 | 68 | 69 | previousSuffix = suffix; 70 | outputIsBold = isBold; 71 | outputIsItalic = isItalic; 72 | outputIsStrikethrough = isStrikethrough; 73 | outputIsLink = isLink; 74 | 75 | didParseWord = YES; 76 | }; 77 | 78 | [inputString enumerateSubstringsInRange:range options:NSStringEnumerationByWords usingBlock:parseBlock]; 79 | // parse surrogate pairs instead 80 | if (!didParseWord) { 81 | [inputString enumerateSubstringsInRange:range options:NSStringEnumerationByComposedCharacterSequences usingBlock:parseBlock]; 82 | } 83 | 84 | if (outputIsBold) [output appendString:@"**"]; 85 | if (outputIsItalic) [output appendString:@"*"]; 86 | if (outputIsStrikethrough) [output appendString:@"~~"]; 87 | if (previousSuffix) [output appendString:previousSuffix]; 88 | if (outputIsLink) [output appendFormat:@"](%@)", outputLink]; 89 | } 90 | 91 | @implementation AshtonMarkdownWriter 92 | 93 | + (instancetype)sharedInstance { 94 | static dispatch_once_t onceToken; 95 | static AshtonMarkdownWriter *sharedInstance; 96 | dispatch_once(&onceToken, ^{ 97 | sharedInstance = [[AshtonMarkdownWriter alloc] init]; 98 | }); 99 | return sharedInstance; 100 | } 101 | 102 | - (NSString *)markdownStringFromAttributedString:(NSAttributedString *)input { 103 | if (input.length == 0) { 104 | return @""; 105 | } 106 | 107 | #if TARGET_OS_IPHONE 108 | input = [[AshtonUIKit sharedInstance] intermediateRepresentationWithTargetRepresentation:input]; 109 | #else 110 | input = [[AshtonAppKit sharedInstance] intermediateRepresentationWithTargetRepresentation:input]; 111 | #endif 112 | 113 | NSString *inputString = input.string; 114 | NSMutableString *output = [NSMutableString stringWithCapacity:input.length*1.5]; 115 | NSUInteger length = [input length]; 116 | NSUInteger paraStart = 0, paraEnd = 0, contentsEnd = 0; 117 | NSRange paragraphRange; 118 | 119 | while (paraEnd < length) { 120 | [inputString getParagraphStart:¶Start end:¶End 121 | contentsEnd:&contentsEnd forRange:NSMakeRange(paraEnd, 0)]; 122 | paragraphRange = NSMakeRange(paraStart, contentsEnd - paraStart); 123 | writeMarkdownFragment(input, inputString, paragraphRange, output); 124 | if (paraEnd < length) { 125 | [output appendFormat:@" \n"]; 126 | } 127 | } 128 | return output; 129 | } 130 | 131 | @end 132 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmarkTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AshtonBechmarkTests.swift 3 | // AshtonBenchmarkTests 4 | // 5 | // Created by Michael Schwarz on 12.11.20. 6 | // Copyright © 2017 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Ashton 11 | 12 | 13 | /// Tests for comparing Ashton 2.0 with the 1.0 (objc) reference implementation 14 | /// Because of Objc dependency and resource handling those tests are excluded from SPM and only executed via project 15 | class AshtonBenchmarkTests: XCTestCase { 16 | 17 | func testTextStyles() { 18 | let attributedString = self.loadAttributedString(fromRTF: "TextStyles") 19 | let html = Ashton.encode(attributedString) 20 | let roundTripAttributedString = Ashton.decode(html) 21 | let roundTripHTML = Ashton.encode(roundTripAttributedString) 22 | XCTAssertEqual(html, roundTripHTML) 23 | } 24 | 25 | func testRTFTestFileRoundTrip() { 26 | let attributedString = self.loadAttributedString(fromRTF: "RTFText") 27 | 28 | let html = Ashton.encode(attributedString) 29 | let decodedString = Ashton.decode(html) 30 | let roundTripHTML = Ashton.encode(decodedString) 31 | let roundTripDecodedString = Ashton.decode(roundTripHTML) 32 | XCTAssertEqual(roundTripHTML, html) 33 | XCTAssertEqual(roundTripDecodedString, decodedString) 34 | } 35 | 36 | func testVerticalAlignment() { 37 | let key = NSAttributedString.Key(rawValue: "NSSuperScript") 38 | self.compareAttributeCodingWithBenchmark(key, values: [2, -2], ignoreReferenceHTML: true) 39 | } 40 | 41 | func testURLs() { 42 | let urlString = URL(string: "https://www.orf.at")! 43 | self.compareAttributeCodingWithBenchmark(.link, values: [urlString], ignoreReferenceHTML: true) 44 | } 45 | 46 | func testAttributeCodingWithBenchmark() { 47 | let testColors = [Color.red, Color.green] 48 | self.compareAttributeCodingWithBenchmark(.backgroundColor, values: testColors, ignoreReferenceHTML: true) 49 | self.compareAttributeCodingWithBenchmark(.foregroundColor, values: testColors, ignoreReferenceHTML: true) 50 | self.compareAttributeCodingWithBenchmark(.strikethroughColor, values: testColors, ignoreReferenceHTML: true) 51 | self.compareAttributeCodingWithBenchmark(.underlineColor, values: testColors, ignoreReferenceHTML: true) 52 | let underlineStyles: [NSUnderlineStyle] = [NSUnderlineStyle.single]//, .styleThick, .styleDouble] 53 | self.compareAttributeCodingWithBenchmark(.underlineStyle, values: underlineStyles.map { $0.rawValue }, ignoreReferenceHTML: true) 54 | self.compareAttributeCodingWithBenchmark(.strikethroughStyle, values: underlineStyles.map { $0.rawValue }, ignoreReferenceHTML: true) 55 | } 56 | 57 | func testArabicCharacterParsing() { 58 | let html = "

لالالالالالالالالا

" 59 | let attributedString = Ashton.decode(html) 60 | let reference = NSAttributedString(htmlString: html)! 61 | let canvasSize = CGSize(width: 200.0, height: 200.0) 62 | let drawingRect = attributedString.boundingRect(with: canvasSize, options: [.usesDeviceMetrics], context: nil) 63 | let referenceRect = reference.boundingRect(with: canvasSize, options: [.usesDeviceMetrics], context: nil) 64 | XCTAssertEqual(drawingRect, referenceRect) 65 | XCTAssertEqual(attributedString.string, reference.string) 66 | } 67 | 68 | func testAttributeDecodingPerformance() { 69 | let attributedString = NSMutableAttributedString(string: "Test: Any attribute with Benchmark.\n\nNext line with no attribute") 70 | attributedString.addAttribute(.backgroundColor, 71 | value: Color.green, 72 | range: NSRange(location: 6, length: 10)) 73 | 74 | let referenceHtml = attributedString.mn_HTMLRepresentation()! 75 | 76 | self.measure { 77 | for _ in 0...1000 { 78 | //_ = NSAttributedString(htmlString: referenceHtml) // old ashton benchmark 79 | _ = Ashton.decode(referenceHtml) 80 | } 81 | } 82 | } 83 | 84 | func testSampleRTFTextDecodingPerformance() { 85 | let attributedString = self.loadAttributedString(fromRTF: "RTFText") 86 | let html = Ashton.encode(attributedString) + "" 87 | self.measure { 88 | // let test1 = NSAttributedString(htmlString: html) // old ashton benchmark 89 | // let test2 = NSAttributedString(htmlString: html) // old ashton benchmark 90 | let test1 = Ashton.decode(html) 91 | let test2 = Ashton.decode(html) 92 | XCTAssertEqual(test1, test2) 93 | } 94 | } 95 | 96 | func testSampleRTFTextEncodingPerformance() { 97 | let attributedString = self.loadAttributedString(fromRTF: "RTFText") 98 | self.measure { 99 | //let test1 = attributedString.mn_HTMLRepresentation()! // old ashton benchmark 100 | //let test2 = attributedString.mn_HTMLRepresentation()! // old ashton benchmark 101 | let test1 = Ashton.encode(attributedString) 102 | let test2 = Ashton.encode(attributedString) 103 | XCTAssertEqual(test1, test2) 104 | } 105 | } 106 | } 107 | 108 | // MARK: - Private 109 | 110 | private extension AshtonBenchmarkTests { 111 | 112 | func loadAttributedString(fromRTF fileName: String) -> NSAttributedString { 113 | let rtfURL = Bundle(for: AshtonTests.self).url(forResource: fileName, withExtension: "rtf")! 114 | return try! NSAttributedString(url: rtfURL, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) 115 | } 116 | 117 | func compareAttributeCodingWithBenchmark(_ attribute: NSAttributedString.Key, values: [Any], ignoreReferenceHTML: Bool = false) { 118 | for value in values { 119 | let attributedString = NSMutableAttributedString(string: "Test: Any attribute with Benchmark.\n\nNext line with no attribute") 120 | attributedString.addAttribute(attribute, 121 | value: value, 122 | range: NSRange(location: 6, length: 10)) 123 | let referenceHtml = attributedString.mn_HTMLRepresentation()! 124 | let html = Ashton.encode(attributedString) 125 | if ignoreReferenceHTML == false { 126 | XCTAssertEqual(referenceHtml, html) 127 | } 128 | 129 | let decodedString = Ashton.decode(html) 130 | XCTAssertEqual(decodedString, attributedString) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonCoreText.m: -------------------------------------------------------------------------------- 1 | #import "AshtonCoreText.h" 2 | #import "AshtonIntermediate.h" 3 | #import "AshtonUtils.h" 4 | #import 5 | 6 | @interface AshtonCoreText () 7 | @property (nonatomic, readonly) NSSet *attributesToPreserve; 8 | @end 9 | 10 | @implementation AshtonCoreText 11 | 12 | + (instancetype)sharedInstance { 13 | static dispatch_once_t onceToken; 14 | static AshtonCoreText *sharedInstance; 15 | dispatch_once(&onceToken, ^{ 16 | sharedInstance = [[AshtonCoreText alloc] init]; 17 | }); 18 | return sharedInstance; 19 | } 20 | 21 | - (id)init { 22 | if (self = [super init]) { 23 | _attributesToPreserve = [NSSet setWithObjects: AshtonAttrBackgroundColor, AshtonAttrBaselineOffset, AshtonAttrStrikethrough, AshtonAttrStrikethroughColor, AshtonAttrLink, nil ]; 24 | } 25 | return self; 26 | } 27 | 28 | - (NSAttributedString *)intermediateRepresentationWithTargetRepresentation:(NSAttributedString *)input { 29 | NSMutableAttributedString *output = [input mutableCopy]; 30 | NSRange totalRange = NSMakeRange (0, input.length); 31 | [input enumerateAttributesInRange:totalRange options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { 32 | NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithCapacity:[attrs count]]; 33 | for (id attrName in attrs) { 34 | id attr = attrs[attrName]; 35 | if ([attrName isEqual:(id)kCTParagraphStyleAttributeName]) { 36 | // produces: paragraph 37 | CTParagraphStyleRef paragraphStyle = (__bridge CTParagraphStyleRef)attr; 38 | NSMutableDictionary *attrDict = [NSMutableDictionary dictionary]; 39 | 40 | CTTextAlignment alignment; 41 | CTParagraphStyleGetValueForSpecifier(paragraphStyle, kCTParagraphStyleSpecifierAlignment, sizeof(alignment), &alignment); 42 | 43 | if (alignment == kCTTextAlignmentLeft) attrDict[AshtonParagraphAttrTextAlignment] = @"left"; 44 | if (alignment == kCTTextAlignmentRight) attrDict[AshtonParagraphAttrTextAlignment] = @"right"; 45 | if (alignment == kCTTextAlignmentCenter) attrDict[AshtonParagraphAttrTextAlignment] = @"center"; 46 | if (alignment == kCTTextAlignmentJustified) attrDict[AshtonParagraphAttrTextAlignment] = @"justified"; 47 | newAttrs[AshtonAttrParagraph] = attrDict; 48 | } 49 | if ([attrName isEqual:(id)kCTFontAttributeName]) { 50 | // produces: font 51 | CTFontRef font = (__bridge CTFontRef)attr; 52 | NSMutableDictionary *attrDict = [NSMutableDictionary dictionary]; 53 | 54 | CTFontSymbolicTraits symbolicTraits = CTFontGetSymbolicTraits(font); 55 | if ((symbolicTraits & kCTFontTraitBold) == kCTFontTraitBold) attrDict[AshtonFontAttrTraitBold] = @(YES); 56 | if ((symbolicTraits & kCTFontTraitItalic) == kCTFontTraitItalic) attrDict[AshtonFontAttrTraitItalic] = @(YES); 57 | 58 | NSArray *fontFeatures = CFBridgingRelease(CTFontCopyFeatureSettings(font)); 59 | NSMutableSet *features = [NSMutableSet set]; 60 | for (NSDictionary *feature in fontFeatures) { 61 | [features addObject:@[feature[(id)kCTFontFeatureTypeIdentifierKey], feature[(id)kCTFontFeatureSelectorIdentifierKey]]]; 62 | } 63 | 64 | attrDict[AshtonFontAttrFeatures] = features; 65 | attrDict[AshtonFontAttrPointSize] = @(CTFontGetSize(font)); 66 | attrDict[AshtonFontAttrFamilyName] = CFBridgingRelease(CTFontCopyName(font, kCTFontFamilyNameKey)); 67 | attrDict[AshtonFontAttrPostScriptName] = CFBridgingRelease(CTFontCopyName(font, kCTFontPostScriptNameKey)); 68 | newAttrs[AshtonAttrFont] = attrDict; 69 | } 70 | if ([attrName isEqual:(id)kCTSuperscriptAttributeName]) { 71 | newAttrs[AshtonAttrVerticalAlign] = @([attr integerValue]); 72 | } 73 | if ([attrName isEqual:(id)kCTUnderlineStyleAttributeName]) { 74 | // produces: underline 75 | if ([attr isEqual:@(kCTUnderlineStyleSingle)]) newAttrs[AshtonAttrUnderline] = AshtonUnderlineStyleSingle; 76 | if ([attr isEqual:@(kCTUnderlineStyleThick)]) newAttrs[AshtonAttrUnderline] = AshtonUnderlineStyleThick; 77 | if ([attr isEqual:@(kCTUnderlineStyleDouble)]) newAttrs[AshtonAttrUnderline] = AshtonUnderlineStyleDouble; 78 | } 79 | if ([attrName isEqual:(id)kCTUnderlineColorAttributeName]) { 80 | // produces: underlineColor 81 | newAttrs[AshtonAttrUnderlineColor] = [self arrayForColor:(__bridge CGColorRef)(attr)]; 82 | } 83 | if ([attrName isEqual:(id)kCTForegroundColorAttributeName] || [attrName isEqual:(id)kCTStrokeColorAttributeName]) { 84 | // produces: color 85 | newAttrs[AshtonAttrColor] = [self arrayForColor:(__bridge CGColorRef)(attr)]; 86 | } 87 | if ([self.attributesToPreserve containsObject:attrName]) { 88 | newAttrs[attrName] = attr; 89 | } 90 | } 91 | [output setAttributes:newAttrs range:range]; 92 | }]; 93 | 94 | return output; 95 | } 96 | 97 | - (NSAttributedString *)targetRepresentationWithIntermediateRepresentation:(NSAttributedString *)input { 98 | NSMutableAttributedString *output = [input mutableCopy]; 99 | NSRange totalRange = NSMakeRange (0, input.length); 100 | [input enumerateAttributesInRange:totalRange options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { 101 | NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithCapacity:[attrs count]]; 102 | for (NSString *attrName in attrs) { 103 | id attr = attrs[attrName]; 104 | if ([attrName isEqualToString:AshtonAttrParagraph]) { 105 | // consumes: paragraph 106 | NSDictionary *attrDict = attr; 107 | CTTextAlignment alignment = kCTTextAlignmentNatural; 108 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"left"]) alignment = kCTTextAlignmentLeft; 109 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"right"]) alignment = kCTTextAlignmentRight; 110 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"center"]) alignment = kCTTextAlignmentCenter; 111 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"justified"]) alignment = kCTTextAlignmentJustified; 112 | 113 | CTParagraphStyleSetting settings[] = { 114 | { kCTParagraphStyleSpecifierAlignment, sizeof(CTTextAlignment), &alignment }, 115 | }; 116 | 117 | newAttrs[(id)kCTParagraphStyleAttributeName] = CFBridgingRelease(CTParagraphStyleCreate(settings, sizeof(settings) / sizeof(CTParagraphStyleSetting))); 118 | } 119 | if ([attrName isEqualToString:AshtonAttrFont]) { 120 | // consumes: font 121 | NSDictionary *attrDict = attr; 122 | id font = [AshtonUtils CTFontRefWithFamilyName:attrDict[AshtonFontAttrFamilyName] 123 | postScriptName:attrDict[AshtonFontAttrPostScriptName] 124 | size:[attrDict[AshtonFontAttrPointSize] doubleValue] 125 | boldTrait:[attrDict[AshtonFontAttrTraitBold] isEqual:@(YES)] 126 | italicTrait:[attrDict[AshtonFontAttrTraitItalic] isEqual:@(YES)] 127 | features:attrDict[AshtonFontAttrFeatures]]; 128 | if (font) newAttrs[(id)kCTFontAttributeName] = font; 129 | } 130 | if ([attrName isEqualToString:AshtonAttrVerticalAlign]) { 131 | newAttrs[(id)kCTSuperscriptAttributeName] = @([attr integerValue]); 132 | } 133 | if ([attrName isEqualToString:AshtonAttrUnderline]) { 134 | // consumes: underline 135 | if ([attr isEqualToString:@"single"]) newAttrs[(id)kCTUnderlineStyleAttributeName] = @(kCTUnderlineStyleSingle); 136 | if ([attr isEqualToString:@"thick"]) newAttrs[(id)kCTUnderlineStyleAttributeName] = @(kCTUnderlineStyleThick); 137 | if ([attr isEqualToString:@"double"]) newAttrs[(id)kCTUnderlineStyleAttributeName] = @(kCTUnderlineStyleDouble); 138 | } 139 | if ([attrName isEqualToString:AshtonAttrUnderlineColor]) { 140 | // consumes: underlineColor 141 | newAttrs[(id)kCTUnderlineColorAttributeName] = [self colorForArray:attr]; 142 | } 143 | if ([attrName isEqualToString:AshtonAttrColor]) { 144 | // consumes: color 145 | newAttrs[(id)kCTForegroundColorAttributeName] = [self colorForArray:attr]; 146 | } 147 | if ([self.attributesToPreserve containsObject:attrName]) { 148 | newAttrs[attrName] = attr; 149 | } 150 | } 151 | [output setAttributes:newAttrs range:range]; 152 | }]; 153 | 154 | return output; 155 | } 156 | 157 | - (NSArray *)arrayForColor:(CGColorRef)color { 158 | return [AshtonUtils arrayForCGColor:color]; 159 | } 160 | 161 | - (id)colorForArray:(NSArray *)input { 162 | const CGFloat components[] = { [input[0] doubleValue], [input[1] doubleValue], [input[2] doubleValue], [input[3] doubleValue] }; 163 | CGColorSpaceRef colorspace = CGColorSpaceCreateDeviceRGB(); 164 | id color = CFBridgingRelease(CGColorCreate(colorspace, components)); 165 | CFRelease(colorspace); 166 | return color; 167 | } 168 | 169 | @end -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonUIKit.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #if TARGET_OS_IPHONE 4 | #import "AshtonUIKit.h" 5 | #import "AshtonIntermediate.h" 6 | #import "AshtonUtils.h" 7 | #import "AshtonCoreText.h" 8 | #import 9 | 10 | @interface AshtonUIKit () 11 | @property (nonatomic, readonly) NSArray *attributesToPreserve; 12 | @end 13 | 14 | @implementation AshtonUIKit 15 | 16 | + (instancetype)sharedInstance { 17 | static dispatch_once_t onceToken; 18 | static AshtonUIKit *sharedInstance; 19 | dispatch_once(&onceToken, ^{ 20 | sharedInstance = [[AshtonUIKit alloc] init]; 21 | }); 22 | return sharedInstance; 23 | } 24 | 25 | - (id)init { 26 | if (self = [super init]) { 27 | _attributesToPreserve = @[ AshtonAttrBaselineOffset, AshtonAttrVerticalAlign ]; 28 | } 29 | return self; 30 | } 31 | 32 | - (NSAttributedString *)intermediateRepresentationWithTargetRepresentation:(NSAttributedString *)input { 33 | NSMutableAttributedString *output = [input mutableCopy]; 34 | NSRange totalRange = NSMakeRange (0, input.length); 35 | [input enumerateAttributesInRange:totalRange options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { 36 | NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithCapacity:[attrs count]]; 37 | for (id attrName in attrs) { 38 | id attr = attrs[attrName]; 39 | if ([attrName isEqual:NSParagraphStyleAttributeName]) { 40 | // produces: paragraph 41 | if (![attr isKindOfClass:[NSParagraphStyle class]]) continue; 42 | NSParagraphStyle *paragraphStyle = attr; 43 | NSMutableDictionary *attrDict = [NSMutableDictionary dictionary]; 44 | 45 | if (paragraphStyle.alignment == NSTextAlignmentLeft) attrDict[AshtonParagraphAttrTextAlignment] = @"left"; 46 | if (paragraphStyle.alignment == NSTextAlignmentRight) attrDict[AshtonParagraphAttrTextAlignment] = @"right"; 47 | if (paragraphStyle.alignment == NSTextAlignmentCenter) attrDict[AshtonParagraphAttrTextAlignment] = @"center"; 48 | if (paragraphStyle.alignment == NSTextAlignmentJustified) attrDict[AshtonParagraphAttrTextAlignment] = @"justified"; 49 | newAttrs[AshtonAttrParagraph] = attrDict; 50 | } 51 | if ([attrName isEqual:NSFontAttributeName]) { 52 | // produces: font 53 | if (![attr isKindOfClass:[UIFont class]]) continue; 54 | UIFont *font = attr; 55 | UIFontDescriptor *fontDescriptor = font.fontDescriptor; 56 | NSMutableDictionary *attrDict = [NSMutableDictionary dictionary]; 57 | 58 | CTFontRef ctFont = CTFontCreateWithName((__bridge CFStringRef)font.fontName, font.pointSize, NULL); 59 | CTFontSymbolicTraits symbolicTraits = CTFontGetSymbolicTraits(ctFont); 60 | if ((symbolicTraits & kCTFontTraitBold) == kCTFontTraitBold) attrDict[AshtonFontAttrTraitBold] = @(YES); 61 | if ((symbolicTraits & kCTFontTraitItalic) == kCTFontTraitItalic) attrDict[AshtonFontAttrTraitItalic] = @(YES); 62 | 63 | // non-default font feature settings 64 | NSArray *fontFeatures = [fontDescriptor objectForKey:UIFontDescriptorFeatureSettingsAttribute]; 65 | NSMutableSet *features = [NSMutableSet set]; 66 | if (fontFeatures) { 67 | for (NSDictionary *feature in fontFeatures) { 68 | [features addObject:@[feature[UIFontFeatureTypeIdentifierKey], feature[UIFontFeatureSelectorIdentifierKey]]]; 69 | } 70 | } 71 | 72 | attrDict[AshtonFontAttrFeatures] = features; 73 | attrDict[AshtonFontAttrPointSize] = @(font.pointSize); 74 | attrDict[AshtonFontAttrFamilyName] = CFBridgingRelease(CTFontCopyName(ctFont, kCTFontFamilyNameKey)); 75 | attrDict[AshtonFontAttrPostScriptName] = CFBridgingRelease(CTFontCopyName(ctFont, kCTFontPostScriptNameKey)); 76 | CFRelease(ctFont); 77 | newAttrs[AshtonAttrFont] = attrDict; 78 | } 79 | if ([attrName isEqual:NSUnderlineStyleAttributeName]) { 80 | // produces: underline 81 | if (![attr isKindOfClass:[NSNumber class]]) continue; 82 | if ([attr isEqual:@(NSUnderlineStyleSingle)]) newAttrs[AshtonAttrUnderline] = AshtonUnderlineStyleSingle; 83 | } 84 | if ([attrName isEqual:NSStrikethroughStyleAttributeName]) { 85 | // produces: strikthrough 86 | if (![attr isKindOfClass:[NSNumber class]]) continue; 87 | if ([attr isEqual:@(NSUnderlineStyleSingle)]) newAttrs[AshtonAttrStrikethrough] = AshtonStrikethroughStyleSingle; 88 | } 89 | if ([attrName isEqual:NSForegroundColorAttributeName]) { 90 | // produces: color 91 | if (![attr isKindOfClass:[UIColor class]]) continue; 92 | newAttrs[AshtonAttrColor] = [self arrayForColor:attr]; 93 | } 94 | if ([attrName isEqual:NSBackgroundColorAttributeName]) { 95 | // produces: color 96 | if (![attr isKindOfClass:[UIColor class]]) continue; 97 | newAttrs[AshtonAttrBackgroundColor] = [self arrayForColor:attr]; 98 | } 99 | if ([attrName isEqual:NSUnderlineColorAttributeName]) { 100 | // produces: color 101 | if (![attr isKindOfClass:[UIColor class]]) continue; 102 | newAttrs[AshtonAttrUnderlineColor] = [self arrayForColor:attr]; 103 | } 104 | if ([attrName isEqual:NSStrikethroughColorAttributeName]) { 105 | // produces: color 106 | if (![attr isKindOfClass:[UIColor class]]) continue; 107 | newAttrs[AshtonAttrStrikethroughColor] = [self arrayForColor:attr]; 108 | } 109 | if ([attrName isEqual:NSLinkAttributeName]) { 110 | if ([attr isKindOfClass:[NSURL class]]) { 111 | newAttrs[AshtonAttrLink] = [attr absoluteString]; 112 | } else if ([attr isKindOfClass:[NSString class]]) { 113 | newAttrs[AshtonAttrLink] = attr; 114 | } 115 | } 116 | } 117 | // after going through all UIKit attributes copy back the preserved attributes, but only if they don't exist already 118 | // we don't want to overwrite settings that were assigned by UIKit with our preserved attributes 119 | for (id attrName in attrs) { 120 | id attr = attrs[attrName]; 121 | if ([self.attributesToPreserve containsObject:attrName]) { 122 | if(!newAttrs[attrName]) newAttrs[attrName] = attr; 123 | } 124 | } 125 | [output setAttributes:newAttrs range:range]; 126 | }]; 127 | 128 | return output; 129 | } 130 | 131 | - (NSAttributedString *)targetRepresentationWithIntermediateRepresentation:(NSAttributedString *)input { 132 | NSMutableAttributedString *output = [input mutableCopy]; 133 | NSRange totalRange = NSMakeRange (0, input.length); 134 | [input enumerateAttributesInRange:totalRange options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { 135 | NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithCapacity:[attrs count]]; 136 | for (NSString *attrName in attrs) { 137 | id attr = attrs[attrName]; 138 | if ([attrName isEqualToString:AshtonAttrParagraph]) { 139 | // consumes: paragraph 140 | NSDictionary *attrDict = attr; 141 | NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; 142 | 143 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"left"]) paragraphStyle.alignment = NSTextAlignmentLeft; 144 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"right"]) paragraphStyle.alignment = NSTextAlignmentRight; 145 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"center"]) paragraphStyle.alignment = NSTextAlignmentCenter; 146 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"justified"]) paragraphStyle.alignment = NSTextAlignmentJustified; 147 | 148 | newAttrs[NSParagraphStyleAttributeName] = paragraphStyle; 149 | } 150 | if ([attrName isEqualToString:AshtonAttrFont]) { 151 | // consumes: font 152 | NSDictionary *attrDict = attr; 153 | UIFont *font = [AshtonUtils CTFontRefWithFamilyName:attrDict[AshtonFontAttrFamilyName] 154 | postScriptName:attrDict[AshtonFontAttrPostScriptName] 155 | size:[attrDict[AshtonFontAttrPointSize] doubleValue] 156 | boldTrait:[attrDict[AshtonFontAttrTraitBold] isEqual:@(YES)] 157 | italicTrait:[attrDict[AshtonFontAttrTraitItalic] isEqual:@(YES)] 158 | features:attrDict[AshtonFontAttrFeatures]]; 159 | if (font) { 160 | newAttrs[NSFontAttributeName] = font; 161 | } else { 162 | // If the font is not available on this device (e.g. custom font), fallback to system font 163 | newAttrs[NSFontAttributeName] = [UIFont systemFontOfSize:[attrDict[AshtonFontAttrPointSize] doubleValue]]; 164 | } 165 | } 166 | if ([attrName isEqualToString:AshtonAttrUnderline]) { 167 | // consumes: underline 168 | if ([attr isEqualToString:AshtonUnderlineStyleSingle]) newAttrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); 169 | if ([attr isEqualToString:AshtonUnderlineStyleDouble]) newAttrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); 170 | if ([attr isEqualToString:AshtonUnderlineStyleThick]) newAttrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); 171 | } 172 | if ([attrName isEqualToString:AshtonAttrStrikethrough]) { 173 | if ([attr isEqualToString:AshtonStrikethroughStyleSingle]) newAttrs[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle); 174 | if ([attr isEqualToString:AshtonStrikethroughStyleDouble]) newAttrs[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle); 175 | if ([attr isEqualToString:AshtonStrikethroughStyleThick]) newAttrs[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle); 176 | } 177 | if ([attrName isEqualToString:AshtonAttrLink]) { 178 | NSURL *URL = [NSURL URLWithString:attr]; 179 | if (URL) { 180 | newAttrs[NSLinkAttributeName] = URL; 181 | } 182 | } 183 | if ([attrName isEqualToString:AshtonAttrColor]) { 184 | // consumes: color 185 | newAttrs[NSForegroundColorAttributeName] = [self colorForArray:attr]; 186 | } 187 | if ([attrName isEqualToString:AshtonAttrBackgroundColor]) { 188 | // consumes: backgroundColor 189 | newAttrs[NSBackgroundColorAttributeName] = [self colorForArray:attr]; 190 | } 191 | if ([self.attributesToPreserve containsObject:attrName]) { 192 | newAttrs[attrName] = attr; 193 | } 194 | } 195 | [output setAttributes:newAttrs range:range]; 196 | }]; 197 | 198 | return output; 199 | } 200 | 201 | - (NSArray *)arrayForColor:(UIColor *)color { 202 | return [AshtonUtils arrayForCGColor:color.CGColor]; 203 | } 204 | 205 | - (UIColor *)colorForArray:(NSArray *)input { 206 | CGFloat red = [input[0] doubleValue], green = [input[1] doubleValue], blue = [input[2] doubleValue], alpha = [input[3] doubleValue]; 207 | return [UIColor colorWithRed:red green:green blue:blue alpha:alpha]; 208 | } 209 | 210 | @end 211 | #endif 212 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonHTMLWriter.m: -------------------------------------------------------------------------------- 1 | #import "AshtonHTMLWriter.h" 2 | #import "AshtonIntermediate.h" 3 | 4 | @implementation AshtonHTMLWriter 5 | 6 | + (instancetype)sharedInstance { 7 | static dispatch_once_t onceToken; 8 | static AshtonHTMLWriter *sharedInstance; 9 | dispatch_once(&onceToken, ^{ 10 | sharedInstance = [[AshtonHTMLWriter alloc] init]; 11 | }); 12 | return sharedInstance; 13 | } 14 | 15 | - (NSString *)HTMLStringFromAttributedString:(NSAttributedString *)input { 16 | NSMutableString *output = [NSMutableString string]; 17 | 18 | for (NSAttributedString *paragraph in [self paragraphsForAttributedString:input]) { 19 | NSRange paragraphRange = NSMakeRange(0, paragraph.length); 20 | NSMutableString *paragraphOutput = [NSMutableString string]; 21 | NSMutableDictionary *paragraphAttrs = [NSMutableDictionary dictionary]; 22 | id paragraphStyle = [paragraph attribute:AshtonAttrParagraph atIndex:0 effectiveRange:NULL]; 23 | if (paragraphStyle) paragraphAttrs[AshtonAttrParagraph] = paragraphStyle; 24 | 25 | [paragraph enumerateAttributesInRange:paragraphRange options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { 26 | NSString *content = [self HTMLEscapeString:[paragraph.string substringWithRange:range]]; 27 | if (NSEqualRanges(range, paragraphRange)) { 28 | [paragraphAttrs addEntriesFromDictionary:attrs]; 29 | id link = attrs[AshtonAttrLink]; 30 | NSString *linkStringValue = nil; 31 | if ([link isKindOfClass:[NSString class]]) { 32 | linkStringValue = link; 33 | } else if ([link isKindOfClass:[NSURL class]]) { 34 | linkStringValue = [link absoluteString]; 35 | } 36 | linkStringValue = [self HTMLEscapeString:linkStringValue]; 37 | if (linkStringValue) [paragraphOutput appendFormat:@"", linkStringValue]; 38 | [paragraphOutput appendString:content]; 39 | if (linkStringValue) [paragraphOutput appendString:@""]; 40 | } else { 41 | [paragraphOutput appendString:[self openingTagForAttributes:attrs skipParagraphStyles:YES]]; 42 | [paragraphOutput appendString:content]; 43 | [paragraphOutput appendString:[self closingTagWithAttributes:attrs]]; 44 | } 45 | }]; 46 | 47 | [output appendString:@""]; 50 | [output appendString:paragraphOutput]; 51 | [output appendString:@"

"]; 52 | }; 53 | 54 | return output; 55 | } 56 | 57 | - (NSString *)HTMLEscapeString:(NSString *)input { 58 | input = [input stringByReplacingOccurrencesOfString:@"&" withString:@"&"]; 59 | input = [input stringByReplacingOccurrencesOfString:@"\"" withString:@"""]; 60 | input = [input stringByReplacingOccurrencesOfString:@"'" withString:@"'"]; 61 | input = [input stringByReplacingOccurrencesOfString:@"<" withString:@"<"]; 62 | input = [input stringByReplacingOccurrencesOfString:@">" withString:@">"]; 63 | input = [input stringByReplacingOccurrencesOfString:@"\n" withString:@"
"]; 64 | return input; 65 | } 66 | 67 | - (NSArray *)paragraphsForAttributedString:(NSAttributedString *)input { 68 | NSMutableArray *paragraphs = [NSMutableArray array]; 69 | 70 | NSUInteger length = [input length]; 71 | NSUInteger paraStart = 0, paraEnd = 0, contentsEnd = 0; 72 | NSRange currentRange; 73 | while (paraEnd < length) { 74 | [input.string getParagraphStart:¶Start end:¶End 75 | contentsEnd:&contentsEnd forRange:NSMakeRange(paraEnd, 0)]; 76 | currentRange = NSMakeRange(paraStart, contentsEnd - paraStart); 77 | if (currentRange.length > 0) 78 | [paragraphs addObject:[input attributedSubstringFromRange:currentRange]]; 79 | else 80 | [paragraphs addObject:[[NSAttributedString alloc] init]]; 81 | } 82 | 83 | return paragraphs; 84 | } 85 | 86 | - (NSString *)openingTagForAttributes:(NSDictionary *)attrs skipParagraphStyles:(BOOL)skipParagraphStyles { 87 | NSMutableString *tag = [NSMutableString string]; 88 | [tag appendString:@"<"]; 89 | [tag appendString:[self tagNameForAttributes:attrs]]; 90 | [tag appendString:[self styleStringForAttributes:attrs skipParagraphStyles:skipParagraphStyles]]; 91 | [tag appendString:@">"]; 92 | return tag; 93 | } 94 | 95 | - (NSString *)styleStringForAttributes:(NSDictionary *)attrs skipParagraphStyles:(BOOL)skipParagraphStyles { 96 | NSDictionary *styles = [self stylesForAttributes:attrs skipParagraphStyles:skipParagraphStyles]; 97 | NSMutableString *styleString = [NSMutableString string]; 98 | if ([styles count] > 0) { 99 | [styleString appendString:@" style='"]; 100 | NSArray *sortedKeys = [self sortedStyleKeyArray:[styles allKeys]]; 101 | for (NSString *key in sortedKeys) { 102 | id obj = styles[key]; 103 | [styleString appendString:key]; 104 | [styleString appendString:@": "]; 105 | if ([obj respondsToSelector:@selector(stringValue)]) obj = [obj stringValue]; 106 | [styleString appendString:obj]; 107 | [styleString appendString:@"; "]; 108 | } 109 | [styleString appendString:@"'"]; 110 | } 111 | 112 | if(skipParagraphStyles && attrs[AshtonAttrLink]) { 113 | id link = attrs[AshtonAttrLink]; 114 | NSString *linkStringValue = nil; 115 | if ([link isKindOfClass:[NSString class]]) { 116 | linkStringValue = link; 117 | } else if ([link isKindOfClass:[NSURL class]]) { 118 | linkStringValue = [link absoluteString]; 119 | } 120 | linkStringValue = [self HTMLEscapeString:linkStringValue]; 121 | [styleString appendFormat:@" href='%@'", linkStringValue]; 122 | } 123 | 124 | return styleString; 125 | } 126 | 127 | // Order style keys so that -cocoa styles come after standard styles 128 | - (NSArray *)sortedStyleKeyArray:(NSArray *)keys { 129 | return [keys sortedArrayUsingComparator:^NSComparisonResult(NSString *obj1, NSString *obj2) { 130 | if (obj1.length > 0 && obj2.length > 0) { 131 | unichar char1 = [obj1 characterAtIndex:0]; 132 | unichar char2 = [obj2 characterAtIndex:0]; 133 | if (char1 == '-' && char2 != '-') 134 | return NSOrderedDescending; 135 | if (char1 != '-' && char2 == '-') 136 | return NSOrderedAscending; 137 | } 138 | return [obj1 caseInsensitiveCompare: obj2]; 139 | }]; 140 | } 141 | 142 | - (NSString *)closingTagWithAttributes:(NSDictionary *)attrs { 143 | NSMutableString *tag = [NSMutableString string]; 144 | [tag appendString:@""]; 147 | return tag; 148 | } 149 | 150 | - (NSString *)tagNameForAttributes:(NSDictionary *)attrs { 151 | if (attrs[AshtonAttrLink]) { 152 | return @"a"; 153 | } 154 | return @"span"; 155 | } 156 | 157 | - (NSDictionary *)stylesForAttributes:(NSDictionary *)attrs skipParagraphStyles:(BOOL)skipParagraphStyles { 158 | NSMutableDictionary *styles = [NSMutableDictionary dictionary]; 159 | for (id key in attrs) { 160 | if(skipParagraphStyles && [key isEqualToString:AshtonAttrParagraph]) continue; 161 | [styles addEntriesFromDictionary:[self stylesForAttribute:attrs[key] withName:key]]; 162 | } 163 | return styles; 164 | } 165 | 166 | - (NSDictionary *)stylesForAttribute:(id)attr withName:(NSString *)attrName { 167 | NSMutableDictionary *styles = [NSMutableDictionary dictionary]; 168 | 169 | if ([attrName isEqualToString:AshtonAttrParagraph]) { 170 | NSDictionary *attrDict = attr; 171 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:AshtonParagraphAttrTextAlignmentStyleLeft]) styles[@"text-align"] = @"left"; 172 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:AshtonParagraphAttrTextAlignmentStyleRight]) styles[@"text-align"] = @"right"; 173 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:AshtonParagraphAttrTextAlignmentStyleCenter]) styles[@"text-align"] = @"center"; 174 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:AshtonParagraphAttrTextAlignmentStyleJustified]) styles[@"text-align"] = @"justify"; 175 | } 176 | if ([attrName isEqualToString:AshtonAttrFont]) { 177 | NSDictionary *attrDict = attr; 178 | // see https://developer.mozilla.org/en-US/docs/CSS/font 179 | NSMutableArray *fontStyle = [NSMutableArray array]; 180 | 181 | if ([attrDict[AshtonFontAttrTraitBold] isEqual:@(YES)]) [fontStyle addObject:@"bold"]; 182 | if ([attrDict[AshtonFontAttrTraitItalic] isEqual:@(YES)]) [fontStyle addObject:@"italic"]; 183 | 184 | [fontStyle addObject:[NSString stringWithFormat:@"%gpx", [attrDict[AshtonFontAttrPointSize] floatValue]]]; 185 | [fontStyle addObject:[NSString stringWithFormat:@"\"%@\"", attrDict[AshtonFontAttrFamilyName]]]; 186 | styles[AshtonAttrFont] = [fontStyle componentsJoinedByString:@" "]; 187 | 188 | NSMutableArray *fontFeatures = attrDict[AshtonFontAttrFeatures]; 189 | if ([fontFeatures count] > 0) { 190 | NSMutableArray *features = [NSMutableArray array]; 191 | for (NSArray *feature in fontFeatures) { 192 | [features addObject:[NSString stringWithFormat:@"%@/%@", feature[0], feature[1]]]; 193 | } 194 | styles[@"-cocoa-font-features"] = [features componentsJoinedByString:@" "]; 195 | } 196 | if (attrDict[AshtonFontAttrPostScriptName]) { 197 | styles[@"-cocoa-font-postscriptname"] = [NSString stringWithFormat:@"\"%@\"", attrDict[AshtonFontAttrPostScriptName]]; 198 | } 199 | } 200 | if ([attrName isEqualToString:AshtonAttrVerticalAlign]) { 201 | NSInteger integerValue = [attr integerValue]; 202 | if (integerValue < 0) styles[@"vertical-align"] = @"sub"; 203 | if (integerValue > 0) styles[@"vertical-align"] = @"super"; 204 | if (integerValue != 0) styles[@"-cocoa-vertical-align"] = @(integerValue); 205 | } 206 | if ([attrName isEqualToString:AshtonAttrBaselineOffset]) { 207 | styles[@"-cocoa-baseline-offset"] = @([attr floatValue]); 208 | } 209 | if ([attrName isEqualToString:AshtonAttrUnderline]) { 210 | styles[@"text-decoration"] = @"underline"; 211 | 212 | if ([attr isEqualToString:AshtonUnderlineStyleSingle]) styles[@"-cocoa-underline"] = @"single"; 213 | if ([attr isEqualToString:AshtonUnderlineStyleThick]) styles[@"-cocoa-underline"] = @"thick"; 214 | if ([attr isEqualToString:AshtonUnderlineStyleDouble]) styles[@"-cocoa-underline"] = @"double"; 215 | } 216 | if ([attrName isEqualToString:AshtonAttrUnderlineColor]) { 217 | styles[@"-cocoa-underline-color"] = [self CSSColor:attr]; 218 | } 219 | if ([attrName isEqualToString:AshtonAttrColor]) { 220 | styles[AshtonAttrColor] = [self CSSColor:attr]; 221 | } 222 | if ([attrName isEqualToString:AshtonAttrBackgroundColor]) { 223 | styles[@"background-color"] = [self CSSColor:attr]; 224 | } 225 | 226 | if ([attrName isEqualToString:AshtonAttrStrikethrough]) { 227 | styles[@"text-decoration"] = @"line-through"; 228 | 229 | if ([attr isEqualToString:AshtonStrikethroughStyleSingle]) styles[@"-cocoa-strikethrough"] = @"single"; 230 | if ([attr isEqualToString:AshtonStrikethroughStyleThick]) styles[@"-cocoa-strikethrough"] = @" "; 231 | if ([attr isEqualToString:AshtonStrikethroughStyleDouble]) styles[@"-cocoa-strikethrough"] = @"double"; 232 | } 233 | if ([attrName isEqualToString:AshtonAttrStrikethroughColor]) { 234 | styles[@"-cocoa-strikethrough-color"] = [self CSSColor:attr]; 235 | } 236 | 237 | return styles; 238 | } 239 | 240 | - (NSString *)CSSColor:(NSArray *)color { 241 | return [NSString stringWithFormat:@"rgba(%i, %i, %i, %f)", (int)([color[0] doubleValue] * 255), (int)([color[1] doubleValue] * 255), (int)([color[2] doubleValue] * 255), [color[3] doubleValue]]; 242 | } 243 | 244 | @end 245 | -------------------------------------------------------------------------------- /Tests/IteratorParsingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IteratorParsingTests.swift 3 | // AshtonTests 4 | // 5 | // Created by Michael Schwarz on 19.01.18. 6 | // Copyright © 2018 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Ashton 11 | 12 | 13 | final class IteratorParsingTests: XCTestCase { 14 | 15 | func testRGBAColorParsing() { 16 | let sample = "rgba(255, 20, 128, 0.953000)" 17 | var iterator = sample.unicodeScalars.makeIterator() 18 | let color = iterator.parseColor() 19 | 20 | XCTAssertNotNil(color) 21 | let components = color!.cgColor.components! 22 | 23 | XCTAssertTrue(components[0].almostEquals(1.0)) 24 | XCTAssertTrue(components[1].almostEquals(20.0 / 255.0)) 25 | XCTAssertTrue(components[2].almostEquals(128.0 / 255.0)) 26 | XCTAssertTrue(components[3].almostEquals(0.953)) 27 | } 28 | 29 | func testRGBColorParsing() { 30 | let sample = "rgb(88, 96, 105)" 31 | var iterator = sample.unicodeScalars.makeIterator() 32 | let color = iterator.parseColor() 33 | 34 | XCTAssertNotNil(color) 35 | let components = color!.cgColor.components! 36 | 37 | XCTAssertTrue(components[0].almostEquals(88.0 / 255.0)) 38 | XCTAssertTrue(components[1].almostEquals(96.0 / 255.0)) 39 | XCTAssertTrue(components[2].almostEquals(105.0 / 255.0)) 40 | XCTAssertTrue(components[3].almostEquals(1.0)) 41 | } 42 | 43 | func testNonValidColorParsing() { 44 | let sample = "1jdslk(fa, 96, fd)" 45 | var iterator = sample.unicodeScalars.makeIterator() 46 | let color = iterator.parseColor() 47 | 48 | XCTAssertNil(color) 49 | XCTAssertEqual(iterator.next(), "1") 50 | } 51 | 52 | func testUnderlineStyleParsing() { 53 | let thick = "thick;" 54 | var iterator = thick.unicodeScalars.makeIterator() 55 | XCTAssertEqual(iterator.parseUnderlineStyle(), NSUnderlineStyle.thick) 56 | 57 | let single = "single" 58 | var iterator2 = single.unicodeScalars.makeIterator() 59 | XCTAssertEqual(iterator2.parseUnderlineStyle(), NSUnderlineStyle.single) 60 | 61 | let double = "double" 62 | var iterator3 = double.unicodeScalars.makeIterator() 63 | XCTAssertEqual(iterator3.parseUnderlineStyle(), NSUnderlineStyle.double) 64 | 65 | let quark = "quark" 66 | var iterator4 = quark.unicodeScalars.makeIterator() 67 | XCTAssertNil(iterator4.parseUnderlineStyle()) 68 | XCTAssertEqual(iterator4.next(), "q") 69 | } 70 | 71 | func testTextDecorationStyleParsing() { 72 | let strikethrough = "line-through;" 73 | var iterator = strikethrough.unicodeScalars.makeIterator() 74 | XCTAssertEqual(iterator.parseTextDecoration(), NSAttributedString.Key.strikethroughStyle) 75 | 76 | let underline = "underline" 77 | var iterator2 = underline.unicodeScalars.makeIterator() 78 | XCTAssertEqual(iterator2.parseTextDecoration(), NSAttributedString.Key.underlineStyle) 79 | 80 | let quark = "quark" 81 | var iterator3 = quark.unicodeScalars.makeIterator() 82 | XCTAssertNil(iterator3.parseTextDecoration()) 83 | XCTAssertEqual(iterator3.next(), "q") 84 | } 85 | 86 | func testTextAlignmentParsing() { 87 | let left = "left;" 88 | var iterator = left.unicodeScalars.makeIterator() 89 | XCTAssertEqual(iterator.parseTextAlignment(), NSTextAlignment.left) 90 | 91 | let right = "right" 92 | var iterator2 = right.unicodeScalars.makeIterator() 93 | XCTAssertEqual(iterator2.parseTextAlignment(), NSTextAlignment.right) 94 | 95 | let justify = "justify" 96 | var iterator3 = justify.unicodeScalars.makeIterator() 97 | XCTAssertEqual(iterator3.parseTextAlignment(), NSTextAlignment.justified) 98 | 99 | let center = "center" 100 | var iterator4 = center.unicodeScalars.makeIterator() 101 | XCTAssertEqual(iterator4.parseTextAlignment(), NSTextAlignment.center) 102 | 103 | let quark = "quark" 104 | var iterator5 = quark.unicodeScalars.makeIterator() 105 | XCTAssertNil(iterator5.parseTextAlignment()) 106 | XCTAssertEqual(iterator5.next(), "q") 107 | } 108 | 109 | func testVerticalAlignmentFromStringParsing() { 110 | let superattribute = "super;" 111 | var iterator = superattribute.unicodeScalars.makeIterator() 112 | XCTAssertEqual(iterator.parseVerticalAlignmentFromString(), 1) 113 | 114 | let sub = "sub" 115 | var iterator2 = sub.unicodeScalars.makeIterator() 116 | XCTAssertEqual(iterator2.parseVerticalAlignmentFromString(), -1) 117 | 118 | let quark = "quark" 119 | var iterator5 = quark.unicodeScalars.makeIterator() 120 | XCTAssertNil(iterator5.parseVerticalAlignmentFromString()) 121 | XCTAssertEqual(iterator5.next(), "q") 122 | } 123 | 124 | func testVerticalAlignmentParsing() { 125 | let baselineOffset = "2.5;" 126 | var iterator = baselineOffset.unicodeScalars.makeIterator() 127 | XCTAssertTrue(iterator.parseVerticalAlignment()!.almostEquals(2.5)) 128 | 129 | let baselineOffset2 = "-1.0" 130 | var iterator2 = baselineOffset2.unicodeScalars.makeIterator() 131 | XCTAssertTrue(iterator2.parseVerticalAlignment()!.almostEquals(-1.0)) 132 | 133 | let quark = "quark" 134 | var iterator3 = quark.unicodeScalars.makeIterator() 135 | XCTAssertNil(iterator3.parseVerticalAlignment()) 136 | XCTAssertEqual(iterator3.next(), "q") 137 | } 138 | 139 | func testBaselineOffsetParsing() { 140 | let baselineOffset = "1.5;" 141 | var iterator = baselineOffset.unicodeScalars.makeIterator() 142 | XCTAssertTrue(iterator.parseBaselineOffset()!.almostEquals(1.5)) 143 | 144 | let baselineOffset2 = "1" 145 | var iterator2 = baselineOffset2.unicodeScalars.makeIterator() 146 | XCTAssertTrue(iterator2.parseBaselineOffset()!.almostEquals(1.0)) 147 | 148 | let quark = "quark" 149 | var iterator3 = quark.unicodeScalars.makeIterator() 150 | XCTAssertNil(iterator3.parseBaselineOffset()) 151 | XCTAssertEqual(iterator3.next(), "q") 152 | } 153 | 154 | func testFontParsing() { 155 | let sampleFont = "bold italic 14px \"Helvetica\"" 156 | var iterator = sampleFont.unicodeScalars.makeIterator() 157 | let fontAttributes = iterator.parseFontAttributes() 158 | XCTAssertEqual(fontAttributes.isBold, true) 159 | XCTAssertEqual(fontAttributes.isItalic, true) 160 | XCTAssertTrue(fontAttributes.points!.almostEquals(14.0)) 161 | XCTAssertEqual(fontAttributes.family!, "Helvetica") 162 | 163 | let sample2 = "italic 12.5px \"Helvetica\", \"Arial\", sans-serif; " 164 | var iterator2 = sample2.unicodeScalars.makeIterator() 165 | let fontAttributes2 = iterator2.parseFontAttributes() 166 | XCTAssertEqual(fontAttributes2.isBold, false) 167 | XCTAssertEqual(fontAttributes2.isItalic, true) 168 | XCTAssertTrue(fontAttributes2.points!.almostEquals(12.5)) 169 | XCTAssertEqual(fontAttributes2.family!, "Helvetica") 170 | 171 | let sample3 = "quark " 172 | var iterator3 = sample3.unicodeScalars.makeIterator() 173 | let fontAttributes3 = iterator3.parseFontAttributes() 174 | XCTAssertEqual(fontAttributes3.isBold, false) 175 | XCTAssertEqual(fontAttributes3.isItalic, false) 176 | XCTAssertNil(fontAttributes3.points) 177 | XCTAssertNil(fontAttributes3.family) 178 | } 179 | 180 | func testPostscriptFontNameParsing() { 181 | do { 182 | let sampleFontName = "\"Helvetica\"" 183 | var iterator = sampleFontName.unicodeScalars.makeIterator() 184 | let fontName = iterator.parsePostscriptFontName() 185 | XCTAssertEqual("Helvetica", fontName) 186 | } 187 | 188 | do { 189 | let sampleFontName = #""Helvetica Neue""# 190 | var iterator = sampleFontName.unicodeScalars.makeIterator() 191 | let fontName = iterator.parsePostscriptFontName() 192 | XCTAssertEqual("Helvetica Neue", fontName) 193 | } 194 | } 195 | 196 | func testURLParsing() { 197 | let sampleURL = "\'www.google.at\'" 198 | var iterator = sampleURL.unicodeScalars.makeIterator() 199 | let url = iterator.parseURL() 200 | XCTAssertNotNil(url) 201 | XCTAssertEqual(url!.absoluteString, "www.google.at") 202 | 203 | let sampleURL2 = "www.google.at\"" 204 | var iterator2 = sampleURL2.unicodeScalars.makeIterator() 205 | let url2 = iterator2.parseURL() 206 | XCTAssertNotNil(url2) 207 | XCTAssertEqual(url2!.absoluteString, "www.google.at") 208 | } 209 | 210 | func testEscaping() { 211 | var scanned = "".unicodeScalars 212 | let sampleString = "Hello & World & this "is" 3 > 2; 4 < '2'" 213 | var iterator = sampleString.unicodeScalars.makeIterator() 214 | while let char = iterator.next() { 215 | if char == "&", let escapedChar = iterator.parseEscapedChar() { 216 | scanned.append(escapedChar) 217 | } else { 218 | scanned.append(char) 219 | } 220 | } 221 | XCTAssertEqual(String(scanned), "Hello & World & this \"is\" 3 > 2; 4 < '2'") 222 | } 223 | 224 | func testFontFeaturesParsing() { 225 | let sampleFeatures = "2/1 12/2 4/2" 226 | var iterator = sampleFeatures.unicodeScalars.makeIterator() 227 | let features = iterator.parseFontFeatures() 228 | XCTAssertNotNil(features) 229 | XCTAssertEqual(features.count, 3) 230 | XCTAssertEqual(features[0][FontDescriptor.FeatureKey.cpTypeIdentifier.rawValue], 2) 231 | XCTAssertEqual(features[0][FontDescriptor.FeatureKey.selectorIdentifier.rawValue], 1) 232 | XCTAssertEqual(features[1][FontDescriptor.FeatureKey.cpTypeIdentifier.rawValue], 12) 233 | XCTAssertEqual(features[1][FontDescriptor.FeatureKey.selectorIdentifier.rawValue], 2) 234 | XCTAssertEqual(features[2][FontDescriptor.FeatureKey.cpTypeIdentifier.rawValue], 4) 235 | XCTAssertEqual(features[2][FontDescriptor.FeatureKey.selectorIdentifier.rawValue], 2) 236 | 237 | let sampleFeatures2 = "2/0 12/32 4/2" 238 | var iterator2 = sampleFeatures2.unicodeScalars.makeIterator() 239 | let features2 = iterator2.parseFontFeatures() 240 | XCTAssertNotNil(features2) 241 | XCTAssertEqual(features2.count, 3) 242 | XCTAssertEqual(features2[0][FontDescriptor.FeatureKey.cpTypeIdentifier.rawValue], 2) 243 | XCTAssertEqual(features2[0][FontDescriptor.FeatureKey.selectorIdentifier.rawValue], 0) 244 | XCTAssertEqual(features2[1][FontDescriptor.FeatureKey.cpTypeIdentifier.rawValue], 12) 245 | XCTAssertEqual(features2[1][FontDescriptor.FeatureKey.selectorIdentifier.rawValue], 32) 246 | XCTAssertEqual(features2[2][FontDescriptor.FeatureKey.cpTypeIdentifier.rawValue], 4) 247 | XCTAssertEqual(features2[2][FontDescriptor.FeatureKey.selectorIdentifier.rawValue], 2) 248 | } 249 | 250 | func testHashing() { 251 | let fontFamilies = Font.cpFamilyNames 252 | var fonts: [String] = [] 253 | for family in fontFamilies { 254 | fonts += Font.cpFontNames(forFamilyName: family) 255 | } 256 | var hashes: [Int: String] = [:] 257 | var collisions: [(String, String)] = [] 258 | for fontname in fonts { 259 | var iterator = ("font: " + fontname + "'").unicodeScalars.makeIterator() 260 | let hash = iterator.hash(until: "'") 261 | if let existingFont = hashes[hash] { 262 | collisions.append((existingFont, fontname)) 263 | } else { 264 | hashes[hash] = fontname 265 | } 266 | } 267 | XCTAssertEqual(collisions.count, 0) 268 | } 269 | } 270 | 271 | 272 | // MARK: - Private 273 | 274 | private extension CGFloat { 275 | 276 | func almostEquals(_ other: CGFloat) -> Bool { 277 | return abs(self - other) <= CGFloat.ulpOfOne 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonHTMLReader.m: -------------------------------------------------------------------------------- 1 | #import "AshtonHTMLReader.h" 2 | #import "AshtonIntermediate.h" 3 | 4 | @interface AshtonHTMLReader () 5 | @property (nonatomic, strong) NSXMLParser *parser; 6 | @property (nonatomic, strong) NSMutableAttributedString *output; 7 | @property (nonatomic, strong) NSMutableArray *styleStack; 8 | @end 9 | 10 | @implementation AshtonHTMLReader 11 | 12 | + (instancetype)HTMLReader { 13 | return [[AshtonHTMLReader alloc] init]; 14 | } 15 | 16 | + (NSMutableDictionary *)stylesCache 17 | { 18 | static NSMutableDictionary *stylesCache; 19 | static dispatch_once_t onceToken; 20 | dispatch_once(&onceToken, ^{ 21 | stylesCache = [NSMutableDictionary dictionary]; 22 | }); 23 | return nil; 24 | } 25 | 26 | + (void)clearStylesCache 27 | { 28 | [[self stylesCache] removeAllObjects]; 29 | } 30 | 31 | - (NSAttributedString *)attributedStringFromHTMLString:(NSString *)htmlString { 32 | self.output = [[NSMutableAttributedString alloc] init]; 33 | self.styleStack = [NSMutableArray array]; 34 | NSMutableString *stringToParse = [NSMutableString stringWithCapacity:(htmlString.length + 13)]; 35 | [stringToParse appendString:@""]; 36 | [stringToParse appendString:htmlString]; 37 | [stringToParse appendString:@""]; 38 | self.parser = [[NSXMLParser alloc] initWithData:[stringToParse dataUsingEncoding:NSUTF8StringEncoding]]; 39 | self.parser.delegate = self; 40 | [self.parser parse]; 41 | return self.output; 42 | } 43 | 44 | - (NSDictionary *)attributesForStyleString:(NSString *)styleString href:(NSString *)href { 45 | NSMutableDictionary *attrs; 46 | NSMutableDictionary *stylesCache = [AshtonHTMLReader stylesCache]; 47 | if (styleString) { 48 | NSDictionary *cachedAttr = stylesCache[styleString]; 49 | if (cachedAttr) { 50 | attrs = [cachedAttr mutableCopy]; 51 | } 52 | else { 53 | attrs = [NSMutableDictionary dictionary]; 54 | NSScanner *scanner = [NSScanner scannerWithString:styleString]; 55 | while (![scanner isAtEnd]) { 56 | NSString *key; 57 | NSString *value; 58 | [scanner scanCharactersFromSet:[NSCharacterSet whitespaceAndNewlineCharacterSet] intoString:NULL]; 59 | [scanner scanUpToString:@":" intoString:&key]; 60 | [scanner scanString:@":" intoString:NULL]; 61 | [scanner scanCharactersFromSet:[NSCharacterSet whitespaceAndNewlineCharacterSet] intoString:NULL]; 62 | [scanner scanUpToString:@";" intoString:&value]; 63 | [scanner scanString:@";" intoString:NULL]; 64 | [scanner scanCharactersFromSet:[NSCharacterSet whitespaceAndNewlineCharacterSet] intoString:NULL]; 65 | if ([key isEqualToString:@"text-align"]) { 66 | // produces: paragraph.text-align 67 | NSMutableDictionary *paragraphAttrs = attrs[AshtonAttrParagraph]; 68 | if (!paragraphAttrs) paragraphAttrs = attrs[AshtonAttrParagraph] = [NSMutableDictionary dictionary]; 69 | 70 | if ([value isEqualToString:@"left"]) paragraphAttrs[AshtonParagraphAttrTextAlignment] = AshtonParagraphAttrTextAlignmentStyleLeft; 71 | if ([value isEqualToString:@"right"]) paragraphAttrs[AshtonParagraphAttrTextAlignment] = AshtonParagraphAttrTextAlignmentStyleRight; 72 | if ([value isEqualToString:@"center"]) paragraphAttrs[AshtonParagraphAttrTextAlignment] = AshtonParagraphAttrTextAlignmentStyleCenter; 73 | if ([value isEqualToString:@"justify"]) paragraphAttrs[AshtonParagraphAttrTextAlignment] = AshtonParagraphAttrTextAlignmentStyleJustified; 74 | } 75 | if ([key isEqualToString:@"vertical-align"]) { 76 | // produces verticalAlign 77 | // skip if vertical-align was already assigned by -cocoa-vertical-align 78 | if (!attrs[AshtonAttrVerticalAlign]) { 79 | if ([value isEqualToString:@"sub"]) attrs[AshtonAttrVerticalAlign] = @(-1); 80 | if ([value isEqualToString:@"super"]) attrs[AshtonAttrVerticalAlign] = @(+1); 81 | } 82 | } 83 | if ([key isEqualToString:@"-cocoa-vertical-align"]) { 84 | attrs[AshtonAttrVerticalAlign] = @([value integerValue]); 85 | } 86 | if ([key isEqualToString:@"-cocoa-baseline-offset"]) { 87 | attrs[AshtonAttrBaselineOffset] = @([value floatValue]); 88 | } 89 | if ([key isEqualToString:AshtonAttrFont]) { 90 | // produces: font 91 | NSScanner *scanner = [NSScanner scannerWithString:value]; 92 | BOOL traitBold = [scanner scanString:@"bold " intoString:NULL]; 93 | BOOL traitItalic = [scanner scanString:@"italic " intoString:NULL]; 94 | NSInteger pointSize; 95 | [scanner scanInteger:&pointSize]; 96 | [scanner scanString:@"px " intoString:NULL]; 97 | [scanner scanString:@"\"" intoString:NULL]; 98 | 99 | NSMutableDictionary *fontAttributes = [@{ AshtonFontAttrTraitBold: @(traitBold), AshtonFontAttrTraitItalic: @(traitItalic), AshtonFontAttrPointSize: @(pointSize), AshtonFontAttrFeatures: @[] } mutableCopy]; 100 | 101 | NSString *familyName = nil; 102 | [scanner scanUpToString:@"\"" intoString:&familyName]; 103 | if (familyName != nil) { 104 | fontAttributes[AshtonFontAttrFamilyName] = familyName; 105 | } 106 | 107 | attrs[AshtonAttrFont] = [self mergeFontAttributes:fontAttributes into:attrs[AshtonAttrFont]]; 108 | } 109 | if ([key isEqualToString:@"-cocoa-font-postscriptname"]) { 110 | NSScanner *scanner = [NSScanner scannerWithString:value]; 111 | [scanner scanString:@"\"" intoString:NULL]; 112 | NSString *postScriptName; [scanner scanUpToString:@"\"" intoString:&postScriptName]; 113 | NSDictionary *fontAttrs = @{ AshtonFontAttrPostScriptName:postScriptName }; 114 | attrs[AshtonAttrFont] = [self mergeFontAttributes:fontAttrs into:attrs[AshtonAttrFont]]; 115 | } 116 | if ([key isEqualToString:@"-cocoa-font-features"]) { 117 | NSMutableArray *features = [NSMutableArray array]; 118 | for (NSString *feature in [value componentsSeparatedByString:@" "]) { 119 | NSArray *values = [feature componentsSeparatedByString:@"/"]; 120 | [features addObject:@[@([values[0] integerValue]), @([values[1] integerValue])]]; 121 | } 122 | 123 | NSDictionary *fontAttrs = @{ AshtonFontAttrFeatures: features }; 124 | attrs[AshtonAttrFont] = [self mergeFontAttributes:fontAttrs into:attrs[AshtonAttrFont]]; 125 | } 126 | 127 | if ([key isEqualToString:@"-cocoa-underline"]) { 128 | // produces: underline 129 | if ([value isEqualToString:@"single"]) attrs[AshtonAttrUnderline] = AshtonUnderlineStyleSingle; 130 | if ([value isEqualToString:@"thick"]) attrs[AshtonAttrUnderline] = AshtonUnderlineStyleThick; 131 | if ([value isEqualToString:@"double"]) attrs[AshtonAttrUnderline] = AshtonUnderlineStyleDouble; 132 | } 133 | if ([key isEqualToString:@"-cocoa-underline-color"]) { 134 | // produces: underlineColor 135 | attrs[AshtonAttrUnderlineColor] = [self colorForCSS:value]; 136 | } 137 | if ([key isEqualToString:AshtonAttrColor]) { 138 | // produces: color 139 | attrs[AshtonAttrColor] = [self colorForCSS:value]; 140 | } 141 | if ([key isEqualToString:@"background-color"]) { 142 | // produces backgroundColor 143 | attrs[AshtonAttrBackgroundColor] = [self colorForCSS:value]; 144 | } 145 | if ([key isEqualToString:@"-cocoa-strikethrough"]) { 146 | // produces: strikethrough 147 | if ([value isEqualToString:@"single"]) attrs[AshtonAttrStrikethrough] = AshtonStrikethroughStyleSingle; 148 | if ([value isEqualToString:@"thick"]) attrs[AshtonAttrStrikethrough] = AshtonStrikethroughStyleThick; 149 | if ([value isEqualToString:@"double"]) attrs[AshtonAttrStrikethrough] = AshtonStrikethroughStyleDouble; 150 | } 151 | if ([key isEqualToString:@"-cocoa-strikethrough-color"]) { 152 | // produces: strikethroughColor 153 | attrs[AshtonAttrStrikethroughColor] = [self colorForCSS:value]; 154 | } 155 | stylesCache[styleString] = [attrs copy]; 156 | } 157 | } 158 | } 159 | 160 | if (!attrs) { 161 | attrs = [NSMutableDictionary dictionary]; 162 | } 163 | 164 | if (href) { 165 | attrs[AshtonAttrLink] = href; 166 | } 167 | 168 | return [attrs copy]; 169 | } 170 | 171 | // Merge AshtonAttrFont if it already exists (e.g. if -cocoa-font-features: happened before font:) 172 | - (NSDictionary *)mergeFontAttributes:(NSDictionary *)new into:(NSDictionary *)existing { 173 | if (existing) { 174 | NSMutableDictionary *merged = [existing mutableCopy]; 175 | NSArray *mergedFeatures; 176 | if (existing[AshtonFontAttrFeatures] && new[AshtonFontAttrFeatures]) mergedFeatures = [existing[AshtonFontAttrFeatures] arrayByAddingObjectsFromArray:new[AshtonFontAttrFeatures]]; 177 | [merged addEntriesFromDictionary:new]; 178 | if (mergedFeatures) merged[AshtonFontAttrFeatures] = mergedFeatures; 179 | return merged; 180 | } else { 181 | return new; 182 | } 183 | } 184 | 185 | - (NSDictionary *)currentAttributes { 186 | NSMutableDictionary *mergedAttrs = [NSMutableDictionary dictionary]; 187 | for (NSDictionary *attrs in self.styleStack) { 188 | [mergedAttrs addEntriesFromDictionary:attrs]; 189 | } 190 | return mergedAttrs; 191 | } 192 | 193 | - (void)parserDidStartDocument:(NSXMLParser *)parser { 194 | [self.output beginEditing]; 195 | } 196 | 197 | - (void)parserDidEndDocument:(NSXMLParser *)parser { 198 | [self.output endEditing]; 199 | } 200 | 201 | - (void)parser:(NSXMLParser *)parser didStartElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qName attributes:(NSDictionary *)attributeDict { 202 | if ([elementName isEqualToString:@"html"]) return; 203 | if (self.output.length > 0) { 204 | if ([elementName isEqualToString:@"p"]) [self.output appendAttributedString:[[NSAttributedString alloc] initWithString:@"\n" attributes:[self.output attributesAtIndex:self.output.length-1 effectiveRange:NULL]]]; 205 | } 206 | [self.styleStack addObject:[self attributesForStyleString:attributeDict[@"style"] href:attributeDict[@"href"]]]; 207 | } 208 | 209 | - (void)parser:(NSXMLParser *)parser didEndElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qName { 210 | if ([elementName isEqualToString:@"html"]) return; 211 | [self.styleStack removeLastObject]; 212 | } 213 | 214 | - (void)parser:(NSXMLParser *)parser parseErrorOccurred:(NSError *)parseError { 215 | NSLog(@"error %@", parseError); 216 | } 217 | 218 | - (void)parser:(NSXMLParser *)parser foundCharacters:(NSString *)string { 219 | NSAttributedString *fragment = [[NSAttributedString alloc] initWithString:string attributes:[self currentAttributes]]; 220 | [self.output appendAttributedString:fragment]; 221 | } 222 | 223 | - (id)colorForCSS:(NSString *)css { 224 | NSScanner *scanner = [NSScanner scannerWithString:css]; 225 | [scanner scanString:@"rgba(" intoString:NULL]; 226 | int red; [scanner scanInt:&red]; 227 | [scanner scanString:@", " intoString:NULL]; 228 | int green; [scanner scanInt:&green]; 229 | [scanner scanString:@", " intoString:NULL]; 230 | int blue; [scanner scanInt:&blue]; 231 | [scanner scanString:@", " intoString:NULL]; 232 | float alpha; [scanner scanFloat:&alpha]; 233 | 234 | return @[ @((float)red / 255), @((float)green / 255), @((float)blue / 255), @(alpha) ]; 235 | } 236 | @end 237 | -------------------------------------------------------------------------------- /Tests/AshtonBenchmark/AshtonAppKit.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #if TARGET_OS_OSX 4 | #import "AshtonAppKit.h" 5 | #import "AshtonIntermediate.h" 6 | #import "AshtonUtils.h" 7 | 8 | @implementation AshtonAppKit 9 | 10 | + (instancetype)sharedInstance { 11 | static dispatch_once_t onceToken; 12 | static AshtonAppKit *sharedInstance; 13 | dispatch_once(&onceToken, ^{ 14 | sharedInstance = [[AshtonAppKit alloc] init]; 15 | }); 16 | return sharedInstance; 17 | } 18 | 19 | - (NSAttributedString *)intermediateRepresentationWithTargetRepresentation:(NSAttributedString *)input { 20 | NSMutableAttributedString *output = [input mutableCopy]; 21 | NSRange totalRange = NSMakeRange (0, input.length); 22 | [input enumerateAttributesInRange:totalRange options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { 23 | NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithCapacity:[attrs count]]; 24 | for (NSString *attrName in attrs) { 25 | id attr = attrs[attrName]; 26 | if ([attrName isEqual:NSParagraphStyleAttributeName]) { 27 | // produces: paragraph 28 | if (![attr isKindOfClass:[NSParagraphStyle class]]) continue; 29 | NSParagraphStyle *paragraphStyle = (NSParagraphStyle *)attr; 30 | NSMutableDictionary *attrDict = [NSMutableDictionary dictionary]; 31 | 32 | if ([paragraphStyle alignment] == NSLeftTextAlignment) attrDict[AshtonParagraphAttrTextAlignment] = @"left"; 33 | if ([paragraphStyle alignment] == NSRightTextAlignment) attrDict[AshtonParagraphAttrTextAlignment] = @"right"; 34 | if ([paragraphStyle alignment] == NSCenterTextAlignment) attrDict[AshtonParagraphAttrTextAlignment] = @"center"; 35 | if ([paragraphStyle alignment] == NSJustifiedTextAlignment) attrDict[AshtonParagraphAttrTextAlignment] = @"justified"; 36 | 37 | newAttrs[AshtonAttrParagraph] = attrDict; 38 | } 39 | if ([attrName isEqual:NSFontAttributeName]) { 40 | // produces: font 41 | if (![attr isKindOfClass:[NSFont class]]) continue; 42 | NSFont *font = (NSFont *)attr; 43 | NSMutableDictionary *attrDict = [NSMutableDictionary dictionary]; 44 | NSFontDescriptor *fontDescriptor = [font fontDescriptor]; 45 | NSFontSymbolicTraits symbolicTraits = [fontDescriptor symbolicTraits]; 46 | if ((symbolicTraits & NSFontBoldTrait) == NSFontBoldTrait) attrDict[AshtonFontAttrTraitBold] = @(YES); 47 | if ((symbolicTraits & NSFontItalicTrait) == NSFontItalicTrait) attrDict[AshtonFontAttrTraitItalic] = @(YES); 48 | 49 | // non-default font feature settings 50 | NSArray *fontFeatures = [fontDescriptor objectForKey:NSFontFeatureSettingsAttribute]; 51 | NSMutableSet *features = [NSMutableSet set]; 52 | if (fontFeatures) { 53 | for (NSDictionary *feature in fontFeatures) { 54 | [features addObject:@[feature[NSFontFeatureTypeIdentifierKey], feature[NSFontFeatureSelectorIdentifierKey]]]; 55 | } 56 | } 57 | 58 | attrDict[AshtonFontAttrFeatures] = features; 59 | attrDict[AshtonFontAttrPointSize] = @(font.pointSize); 60 | attrDict[AshtonFontAttrFamilyName] = font.familyName; 61 | attrDict[AshtonFontAttrPostScriptName] = font.fontName; 62 | newAttrs[AshtonAttrFont] = attrDict; 63 | } 64 | if ([attrName isEqual:NSSuperscriptAttributeName]) { 65 | if (![attr isKindOfClass:[NSNumber class]]) continue; 66 | newAttrs[AshtonAttrVerticalAlign] = @([attr integerValue]); 67 | } 68 | if ([attrName isEqual:NSBaselineOffsetAttributeName]) { 69 | newAttrs[AshtonAttrBaselineOffset] = @([attr floatValue]); 70 | } 71 | if ([attrName isEqual:NSUnderlineStyleAttributeName]) { 72 | // produces: underline 73 | if (![attr isKindOfClass:[NSNumber class]]) continue; 74 | if ([attr isEqual:@(NSUnderlineStyleSingle)]) newAttrs[AshtonAttrUnderline] = AshtonUnderlineStyleSingle; 75 | if ([attr isEqual:@(NSUnderlineStyleThick)]) newAttrs[AshtonAttrUnderline] = AshtonUnderlineStyleThick; 76 | if ([attr isEqual:@(NSUnderlineStyleDouble)]) newAttrs[AshtonAttrUnderline] = AshtonUnderlineStyleDouble; 77 | } 78 | if ([attrName isEqual:NSUnderlineColorAttributeName]) { 79 | // produces: underlineColor 80 | if (![attr isKindOfClass:[NSColor class]]) continue; 81 | newAttrs[AshtonAttrUnderlineColor] = [self arrayForColor:attr]; 82 | } 83 | if ([attrName isEqual:NSForegroundColorAttributeName] || [attrName isEqual:NSStrokeColorAttributeName]) { 84 | // produces: color 85 | if (![attr isKindOfClass:[NSColor class]]) continue; 86 | newAttrs[AshtonAttrColor] = [self arrayForColor:attr]; 87 | } 88 | if ([attrName isEqual:NSBackgroundColorAttributeName]) { 89 | // produces: backgroundColor 90 | if (![attr isKindOfClass:[NSColor class]]) continue; 91 | newAttrs[AshtonAttrBackgroundColor] = [self arrayForColor:attr]; 92 | } 93 | 94 | if ([attrName isEqual:NSStrikethroughStyleAttributeName]) { 95 | // produces: strikethrough 96 | if (![attr isKindOfClass:[NSNumber class]]) continue; 97 | if ([attr isEqual:@(NSUnderlineStyleSingle)]) newAttrs[AshtonAttrStrikethrough] = AshtonStrikethroughStyleSingle; 98 | if ([attr isEqual:@(NSUnderlineStyleThick)]) newAttrs[AshtonAttrStrikethrough] = AshtonStrikethroughStyleThick; 99 | if ([attr isEqual:@(NSUnderlineStyleDouble)]) newAttrs[AshtonAttrStrikethrough] = AshtonStrikethroughStyleDouble; 100 | } 101 | if ([attrName isEqual:NSStrikethroughColorAttributeName]) { 102 | // produces: strikethroughColor 103 | if (![attr isKindOfClass:[NSColor class]]) continue; 104 | newAttrs[AshtonAttrStrikethroughColor] = [self arrayForColor:attr]; 105 | } 106 | if ([attrName isEqual:NSLinkAttributeName]) { 107 | if ([attr isKindOfClass:[NSURL class]]) { 108 | newAttrs[AshtonAttrLink] = [attr absoluteString]; 109 | } else if ([attr isKindOfClass:[NSString class]]) { 110 | newAttrs[AshtonAttrLink] = attr; 111 | } 112 | } 113 | } 114 | [output setAttributes:newAttrs range:range]; 115 | }]; 116 | 117 | return output; 118 | } 119 | 120 | - (NSAttributedString *)targetRepresentationWithIntermediateRepresentation:(NSAttributedString *)input { 121 | NSMutableAttributedString *output = [input mutableCopy]; 122 | NSRange totalRange = NSMakeRange (0, input.length); 123 | [input enumerateAttributesInRange:totalRange options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { 124 | NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithCapacity:[attrs count]]; 125 | for (NSString *attrName in attrs) { 126 | id attr = attrs[attrName]; 127 | if ([attrName isEqualToString:AshtonAttrParagraph]) { 128 | // consumes: paragraph 129 | NSDictionary *attrDict = attr; 130 | NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle defaultParagraphStyle] mutableCopy]; 131 | 132 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"left"]) paragraphStyle.alignment = NSLeftTextAlignment; 133 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"right"]) paragraphStyle.alignment = NSRightTextAlignment; 134 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"center"]) paragraphStyle.alignment = NSCenterTextAlignment; 135 | if ([attrDict[AshtonParagraphAttrTextAlignment] isEqualToString:@"justified"]) paragraphStyle.alignment = NSJustifiedTextAlignment; 136 | 137 | newAttrs[NSParagraphStyleAttributeName] = [paragraphStyle copy]; 138 | } 139 | if ([attrName isEqualToString:AshtonAttrFont]) { 140 | // consumes: font 141 | NSDictionary *attrDict = attr; 142 | NSFont *font = [AshtonUtils CTFontRefWithFamilyName:attrDict[AshtonFontAttrFamilyName] 143 | postScriptName:attrDict[AshtonFontAttrPostScriptName] 144 | size:[attrDict[AshtonFontAttrPointSize] doubleValue] 145 | boldTrait:[attrDict[AshtonFontAttrTraitBold] isEqual:@(YES)] 146 | italicTrait:[attrDict[AshtonFontAttrTraitItalic] isEqual:@(YES)] 147 | features:attrDict[AshtonFontAttrFeatures]]; 148 | if (font) { 149 | newAttrs[NSFontAttributeName] = font; 150 | } else { 151 | // If the font is not available on this device (e.g. custom font) fallback to system font 152 | newAttrs[NSFontAttributeName] = [NSFont systemFontOfSize:[attrDict[AshtonFontAttrPointSize] doubleValue]]; 153 | } 154 | 155 | } 156 | if ([attrName isEqualToString:AshtonAttrVerticalAlign]) { 157 | newAttrs[NSSuperscriptAttributeName] = attr; 158 | } 159 | if ([attrName isEqualToString:AshtonAttrBaselineOffset]) { 160 | newAttrs[NSBaselineOffsetAttributeName] = attr; 161 | } 162 | if ([attrName isEqualToString:AshtonAttrUnderline]) { 163 | // consumes: underline 164 | if ([attr isEqualToString:AshtonUnderlineStyleSingle]) newAttrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); 165 | if ([attr isEqualToString:AshtonUnderlineStyleThick]) newAttrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleThick); 166 | if ([attr isEqualToString:AshtonUnderlineStyleDouble]) newAttrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleDouble); 167 | } 168 | if ([attrName isEqualToString:AshtonAttrUnderlineColor]) { 169 | // consumes: underlineColor 170 | newAttrs[NSUnderlineColorAttributeName] = [self colorForArray:attr]; 171 | } 172 | if ([attrName isEqualToString:AshtonAttrColor]) { 173 | // consumes: color 174 | newAttrs[NSForegroundColorAttributeName] = [self colorForArray:attr]; 175 | } 176 | if ([attrName isEqualToString:AshtonAttrBackgroundColor]) { 177 | // consumes: backgroundColor 178 | newAttrs[NSBackgroundColorAttributeName] = [self colorForArray:attr]; 179 | } 180 | if ([attrName isEqualToString:AshtonAttrStrikethrough]) { 181 | // consumes: strikethrough 182 | if ([attr isEqualToString:AshtonStrikethroughStyleSingle]) newAttrs[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle); 183 | if ([attr isEqualToString:AshtonStrikethroughStyleThick]) newAttrs[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleThick); 184 | if ([attr isEqualToString:AshtonStrikethroughStyleDouble]) newAttrs[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleDouble); 185 | } 186 | if ([attrName isEqualToString:AshtonAttrStrikethroughColor]) { 187 | // consumes strikethroughColor 188 | newAttrs[NSStrikethroughColorAttributeName] = [self colorForArray:attr]; 189 | } 190 | if ([attrName isEqualToString:AshtonAttrLink]) { 191 | NSURL *URL = [NSURL URLWithString:attr]; 192 | if (URL) { 193 | newAttrs[NSLinkAttributeName] = URL; 194 | } 195 | } 196 | } 197 | [output setAttributes:newAttrs range:range]; 198 | }]; 199 | 200 | return output; 201 | } 202 | 203 | 204 | - (NSArray *)arrayForColor:(NSColor *)color { 205 | NSColor *canonicalColor = [color colorUsingColorSpace:[NSColorSpace genericRGBColorSpace]]; 206 | 207 | if (!canonicalColor) { 208 | // We got a color with an image pattern (e.g. windowBackgroundColor) that can't be converted to RGB. 209 | // So we convert it to image and extract the first px. 210 | // The result won't be 100% correct, but better than a completely undefined color. 211 | NSBitmapImageRep *bitmapRep = [[NSBitmapImageRep alloc] initWithBitmapDataPlanes:NULL pixelsWide:1 pixelsHigh:1 bitsPerSample:8 samplesPerPixel:4 hasAlpha:YES isPlanar:NO colorSpaceName:NSCalibratedRGBColorSpace bytesPerRow:4 bitsPerPixel:32]; 212 | NSGraphicsContext *context = [NSGraphicsContext graphicsContextWithBitmapImageRep:bitmapRep]; 213 | 214 | [NSGraphicsContext saveGraphicsState]; 215 | [NSGraphicsContext setCurrentContext:context]; 216 | [color setFill]; 217 | NSRectFill(CGRectMake(0, 0, 1, 1)); 218 | [context flushGraphics]; 219 | [NSGraphicsContext restoreGraphicsState]; 220 | canonicalColor = [bitmapRep colorAtX:0 y:0]; 221 | } 222 | 223 | return @[ @(canonicalColor.redComponent), @(canonicalColor.greenComponent), @(canonicalColor.blueComponent), @(canonicalColor.alphaComponent) ]; 224 | } 225 | 226 | - (NSColor *)colorForArray:(NSArray *)input { 227 | return [NSColor colorWithCalibratedRed:[input[0] doubleValue] green:[input[1] doubleValue] blue:[input[2] doubleValue] alpha:[input[3] doubleValue]]; 228 | } 229 | 230 | @end 231 | #endif 232 | -------------------------------------------------------------------------------- /Sources/Ashton/Iterator+Parsing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Iterator+Parsing.swift 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 19.01.18. 6 | // Copyright © 2018 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | #if os(iOS) || (compiler(>=5.9) && os(visionOS)) 11 | import UIKit 12 | #elseif os(macOS) 13 | import AppKit 14 | #endif 15 | 16 | 17 | /// Parsing helpers 18 | extension String.UnicodeScalarView.Iterator { 19 | 20 | @discardableResult 21 | mutating func forwardIfEquals(_ string: String) -> Bool { 22 | var testingIterator = self 23 | guard testingIterator.forwardAndCheckingIfEquals(string) else { return false } 24 | 25 | self = testingIterator 26 | return true 27 | } 28 | 29 | @discardableResult 30 | mutating func forwardAndCheckingIfEquals(_ string: String) -> Bool { 31 | var referenceIterator = string.unicodeScalars.makeIterator() 32 | while let referenceChar = referenceIterator.next() { 33 | guard referenceChar == self.next() else { return false } 34 | } 35 | return true 36 | } 37 | 38 | mutating func hash(until stopChar: Unicode.Scalar) -> Int { 39 | var hasher = Hasher() 40 | var forwardingIterator = self 41 | while let referenceChar = forwardingIterator.next() { 42 | guard referenceChar != stopChar else { return hasher.finalize() } 43 | 44 | hasher.combine(referenceChar) 45 | } 46 | return hasher.finalize() 47 | } 48 | 49 | mutating func foward(untilAfter stopChar: Unicode.Scalar) { 50 | while let char = self.next(), char != stopChar {} 51 | } 52 | 53 | mutating func scanString(untilBefore stopChar: Unicode.Scalar) -> String { 54 | var scannedScalars = "".unicodeScalars 55 | var scanningIterator = self 56 | 57 | while let char = scanningIterator.next(), char != stopChar { 58 | self = scanningIterator 59 | scannedScalars.append(char) 60 | } 61 | return String(scannedScalars) 62 | } 63 | 64 | mutating func forwardUntilNextAttribute(terminationChar: UnicodeScalar) -> Bool { 65 | var previousPosition = self 66 | while let referenceChar = self.next() { 67 | switch referenceChar { 68 | case " ", ";": 69 | return true 70 | case terminationChar: 71 | self = previousPosition 72 | return false 73 | default: 74 | previousPosition = self 75 | continue 76 | } 77 | } 78 | return false 79 | } 80 | 81 | mutating func skipWhiteSpace() { 82 | var testingIterator = self 83 | while let referenceChar = testingIterator.next() { 84 | switch referenceChar { 85 | case " ": 86 | break 87 | default: 88 | return 89 | } 90 | self = testingIterator 91 | } 92 | } 93 | 94 | mutating func skipStyleAttributeIgnoredCharacters() { 95 | var testingIterator = self 96 | while let referenceChar = testingIterator.next() { 97 | switch referenceChar { 98 | case "=", " ", ";", ":": 99 | break 100 | default: 101 | return 102 | } 103 | self = testingIterator 104 | } 105 | } 106 | 107 | mutating func parseFloat() -> CGFloat? { 108 | let decimalSeparator: UnicodeScalar = "." 109 | var result: CGFloat? = nil 110 | var parsingDecimals = false 111 | var parsingIterator = self 112 | var isNegative = false 113 | var decimalMultiplier: CGFloat = 0.1 114 | while let char = parsingIterator.next() { 115 | if result == nil && char == "-" { 116 | isNegative = true 117 | continue 118 | } 119 | // 48='0', 57='9' 120 | guard char.value >= 48 && char.value <= 57 || char == decimalSeparator else { break } 121 | guard char != decimalSeparator else { 122 | parsingDecimals = true 123 | continue 124 | } 125 | if parsingDecimals == false { 126 | result = (result ?? 0) * 10.0 + CGFloat(char.value - 48) 127 | } else { 128 | result = (result ?? 0) + CGFloat(char.value - 48) * decimalMultiplier 129 | decimalMultiplier = decimalMultiplier * 0.1 130 | } 131 | self = parsingIterator 132 | } 133 | guard let validResult = result else { return nil } 134 | 135 | return isNegative ? -validResult : validResult 136 | } 137 | 138 | func testNextCharacter() -> Unicode.Scalar? { 139 | var copiedIterator = self 140 | return copiedIterator.next() 141 | } 142 | } 143 | 144 | // MARK: - Color Parsing 145 | 146 | extension String.UnicodeScalarView.Iterator { 147 | 148 | mutating func parseColor() -> Color? { 149 | var parsingIterator = self 150 | guard let firstChar = parsingIterator.next(), firstChar == "r" else { return nil } 151 | guard let secondChar = parsingIterator.next(), secondChar == "g" else { return nil } 152 | guard let thirdChar = parsingIterator.next(), thirdChar == "b" else { return nil } 153 | 154 | let fourthChar = parsingIterator.next() 155 | let parseRGBA = fourthChar == "a" 156 | if parseRGBA { _ = parsingIterator.next() } 157 | 158 | func skipIgnoredChars() { 159 | var testingIterator = parsingIterator 160 | while let referenceChar = testingIterator.next() { 161 | guard referenceChar == " " || referenceChar == "," else { return } 162 | 163 | parsingIterator = testingIterator 164 | } 165 | } 166 | 167 | func createColor(r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat = 1.0) -> Color { 168 | return Color(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: a) 169 | } 170 | 171 | skipIgnoredChars() 172 | guard let rValue = parsingIterator.parseFloat() else { return nil } 173 | 174 | skipIgnoredChars() 175 | guard let gValue = parsingIterator.parseFloat() else { return nil } 176 | 177 | skipIgnoredChars() 178 | guard let bValue = parsingIterator.parseFloat() else { return nil } 179 | 180 | guard parseRGBA else { return createColor(r: rValue, g: gValue, b: bValue) } 181 | 182 | skipIgnoredChars() 183 | guard let aValue = parsingIterator.parseFloat() else { return nil } 184 | 185 | return createColor(r: rValue, g: gValue, b: bValue, a: aValue) 186 | } 187 | } 188 | 189 | // MARK: - UnderlineStyle 190 | 191 | extension String.UnicodeScalarView.Iterator { 192 | 193 | mutating func parseUnderlineStyle() -> NSUnderlineStyle? { 194 | guard let firstChar = self.testNextCharacter() else { return nil } 195 | 196 | switch firstChar { 197 | case "s": 198 | return self.forwardIfEquals("single") ? NSUnderlineStyle.single : nil 199 | case "d": 200 | return self.forwardIfEquals("double") ? NSUnderlineStyle.double : nil 201 | case "t": 202 | return self.forwardIfEquals("thick") ? NSUnderlineStyle.thick : nil 203 | default: 204 | return nil 205 | } 206 | } 207 | } 208 | 209 | // MARK: - Text Decoration 210 | 211 | extension String.UnicodeScalarView.Iterator { 212 | 213 | mutating func parseTextDecoration() -> NSAttributedString.Key? { 214 | guard let firstChar = self.testNextCharacter() else { return nil } 215 | 216 | switch firstChar { 217 | case "u": 218 | return self.forwardIfEquals("underline") ? NSAttributedString.Key.underlineStyle : nil 219 | case "l": 220 | return self.forwardIfEquals("line-through") ? NSAttributedString.Key.strikethroughStyle : nil 221 | default: 222 | return nil 223 | } 224 | } 225 | } 226 | 227 | // MARK: - TextAlignment 228 | 229 | extension String.UnicodeScalarView.Iterator { 230 | 231 | mutating func parseTextAlignment() -> NSTextAlignment? { 232 | guard let firstChar = self.testNextCharacter() else { return nil } 233 | 234 | switch firstChar { 235 | case "l": 236 | return self.forwardIfEquals("left") ? NSTextAlignment.left : nil 237 | case "r": 238 | return self.forwardIfEquals("right") ? NSTextAlignment.right : nil 239 | case "j": 240 | return self.forwardIfEquals("justify") ? NSTextAlignment.justified : nil 241 | case "c": 242 | return self.forwardIfEquals("center") ? NSTextAlignment.center : nil 243 | default: 244 | return nil 245 | } 246 | } 247 | } 248 | 249 | // MARK: - Vertical Alignment 250 | 251 | extension String.UnicodeScalarView.Iterator { 252 | 253 | mutating func parseVerticalAlignmentFromString() -> Int? { 254 | guard let firstChar = self.testNextCharacter() else { return nil } 255 | 256 | switch firstChar { 257 | case "s": 258 | if self.forwardIfEquals("sub") { 259 | return -1 260 | } else if self.forwardIfEquals("super") { 261 | return 1 262 | } else { 263 | return nil 264 | } 265 | default: 266 | return nil 267 | } 268 | } 269 | 270 | mutating func parseVerticalAlignment() -> CGFloat? { 271 | return self.parseFloat() 272 | } 273 | } 274 | 275 | // MARK: - Baseline-Offset 276 | 277 | extension String.UnicodeScalarView.Iterator { 278 | 279 | mutating func parseBaselineOffset() -> CGFloat? { 280 | return self.parseFloat() 281 | } 282 | } 283 | 284 | // MARK: - Font 285 | 286 | extension String.UnicodeScalarView.Iterator { 287 | 288 | mutating func parseFontAttributes() -> (isBold: Bool, isItalic: Bool, points: CGFloat?, family: String?) { 289 | let isBold = self.forwardIfEquals("bold ") 290 | let isItalic = self.forwardIfEquals("italic ") 291 | 292 | guard let fontSize = self.parseFloat() else { return (isBold, isItalic, nil, nil) } 293 | 294 | self.forwardIfEquals("px \"") 295 | let familyName = self.scanString(untilBefore: "\"") 296 | 297 | return (isBold, isItalic, fontSize, familyName) 298 | } 299 | 300 | mutating func parsePostscriptFontName() -> String? { 301 | guard self.forwardIfEquals("\"") else { return nil } 302 | 303 | return self.scanString(untilBefore: "\"") 304 | } 305 | } 306 | 307 | // MARK: - Escaping 308 | 309 | extension String.UnicodeScalarView.Iterator { 310 | 311 | mutating func parseEscapedChar() -> UnicodeScalar? { 312 | var parsingIterator = self 313 | var escapeChar: UnicodeScalar? 314 | 315 | guard let firstChar = parsingIterator.next() else { return nil } 316 | switch firstChar { 317 | case "a": 318 | if parsingIterator.forwardIfEquals("mp;") { 319 | escapeChar = "&" 320 | } else if parsingIterator.forwardIfEquals("pos;") { 321 | escapeChar = "'" 322 | } else { 323 | return nil 324 | } 325 | case "q": 326 | guard parsingIterator.forwardIfEquals("uot;") else { return nil } 327 | 328 | escapeChar = "\"" 329 | case "l": 330 | guard parsingIterator.forwardIfEquals("t;") else { return nil } 331 | 332 | escapeChar = "<" 333 | case "g": 334 | guard parsingIterator.forwardIfEquals("t;") else { return nil } 335 | 336 | escapeChar = ">" 337 | default: 338 | return nil 339 | } 340 | 341 | self = parsingIterator 342 | return escapeChar 343 | } 344 | } 345 | 346 | // MARK: - Font Features 347 | 348 | extension String.UnicodeScalarView.Iterator { 349 | 350 | mutating func parseFontFeatures() -> [[String: Int]] { 351 | var parsingIterator = self 352 | var features: [[String: Int]] = [] 353 | var feature: [String: Int] = [:] 354 | var currentFeatureKey = FontDescriptor.FeatureKey.cpTypeIdentifier 355 | 356 | while let char = parsingIterator.next() { 357 | guard char != ";" else { break } 358 | 359 | if char == "/" { 360 | self = parsingIterator 361 | continue 362 | } 363 | 364 | if char == " " { 365 | if feature.keys.count == 2 { 366 | features.append(feature) 367 | feature = [:] 368 | currentFeatureKey = FontDescriptor.FeatureKey.cpTypeIdentifier 369 | } 370 | self = parsingIterator 371 | continue 372 | } 373 | 374 | guard let featureValue = self.parseFloat() else { return features } 375 | 376 | feature[currentFeatureKey.rawValue] = Int(featureValue) 377 | currentFeatureKey = FontDescriptor.FeatureKey.selectorIdentifier 378 | parsingIterator = self 379 | } 380 | if feature.keys.count == 2 { features.append(feature) } 381 | 382 | return features 383 | } 384 | } 385 | 386 | 387 | // MARK: - URL 388 | 389 | extension String.UnicodeScalarView.Iterator { 390 | 391 | mutating func parseURL() -> URL? { 392 | var parsingIterator = self 393 | var isFirstChar = true 394 | var urlChars = "".unicodeScalars 395 | while let char = parsingIterator.next() { 396 | guard char != "'" && char != "\"" else { 397 | if isFirstChar { continue } else { break } 398 | } 399 | isFirstChar = false 400 | if char == "&", let escapedChar = parsingIterator.parseEscapedChar() { 401 | urlChars.append(escapedChar) 402 | } else { 403 | urlChars.append(char) 404 | } 405 | } 406 | guard urlChars.isEmpty == false else { return nil } 407 | guard let url = URL(string: String(urlChars)) else { return nil } 408 | 409 | self = parsingIterator 410 | return url 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /Sources/Ashton/AshtonHTMLWriter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AshtonHTMLWriter.swift 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 16.09.17. 6 | // Copyright © 2017 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | #if os(iOS) || (compiler(>=5.9) && os(visionOS)) 11 | import UIKit 12 | #elseif os(macOS) 13 | import AppKit 14 | #endif 15 | 16 | 17 | public final class AshtonHTMLWriter { 18 | 19 | // MARK: - Lifecycle 20 | 21 | public init() {} 22 | 23 | // MARK: - AshtonHTMLWriter 24 | 25 | public func encode(_ attributedString: NSAttributedString) -> Ashton.HTML { 26 | let string = attributedString.string 27 | let paragraphRanges = self.getParagraphRanges(from: string) 28 | var html = String() 29 | for paragraphRange in paragraphRanges { 30 | var paragraphContent = String() 31 | var nsParagraphRange = NSRange(paragraphRange, in: string) 32 | var paragraphTag = HTMLTag(defaultName: .p, attributes: [:], ignoreParagraphStyles: false) 33 | 34 | // We add an additional character to the paragraph range to get the parsed paragraph separator (e.g \n) 35 | if paragraphRange.isEmpty && paragraphRanges.count > 0 && paragraphRange == paragraphRanges.last { 36 | let fullInputStringLength = (string as NSString).length 37 | if fullInputStringLength >= nsParagraphRange.upperBound + 1 { 38 | nsParagraphRange.length = nsParagraphRange.length + 1 39 | } 40 | } 41 | // We use `attributedString.string as NSString` casts and NSRange ranges in the block because 42 | // we experienced outOfBounds crashes when converting NSRange to Range and using it on swift string 43 | attributedString.enumerateAttributes(in: nsParagraphRange, 44 | options: .longestEffectiveRangeNotRequired, using: { attributes, nsrange, _ in 45 | let paragraphStyle = attributes.filter { $0.key == .paragraphStyle } 46 | paragraphTag.addAttributes(paragraphStyle) 47 | 48 | let nsString = attributedString.string as NSString 49 | if nsParagraphRange.length == nsrange.length { 50 | paragraphTag.addAttributes(attributes) 51 | paragraphContent += String(nsString.substring(with: nsrange)).htmlEscaped 52 | } else { 53 | var tag = HTMLTag(defaultName: .span, attributes: attributes, ignoreParagraphStyles: true) 54 | paragraphContent += tag.parseOpenTag() 55 | paragraphContent += String(nsString.substring(with: nsrange)).htmlEscaped 56 | paragraphContent += tag.makeCloseTag() 57 | } 58 | }) 59 | 60 | html += paragraphTag.parseOpenTag() + paragraphContent + paragraphTag.makeCloseTag() 61 | } 62 | 63 | return html 64 | } 65 | } 66 | 67 | // MARK: - Private 68 | 69 | private extension AshtonHTMLWriter { 70 | 71 | func getParagraphRanges(from string: String) -> [Range] { 72 | var (paragraphStart, paragraphEnd, contentsEnd) = (string.startIndex, string.startIndex, string.startIndex) 73 | var ranges = [Range]() 74 | let length = string.endIndex 75 | 76 | while paragraphEnd < length { 77 | string.getParagraphStart(¶graphStart, end: ¶graphEnd, contentsEnd: &contentsEnd, for: paragraphEnd...paragraphEnd) 78 | ranges.append(paragraphStart.. String { 92 | var attributesString = "" 93 | for attribute in attributes { 94 | if attribute.isEmpty { continue } 95 | 96 | attributesString += " " + attribute 97 | } 98 | return "<\(self.rawValue)\(attributesString)>" 99 | } 100 | 101 | func closeTag() -> String { 102 | return "" 103 | } 104 | } 105 | 106 | private var hasParsedLinks: Bool = false 107 | 108 | // MARK: - Properties 109 | 110 | let defaultName: Name 111 | var attributes: [NSAttributedString.Key: Any] 112 | let ignoreParagraphStyles: Bool 113 | 114 | init(defaultName: Name, attributes: [NSAttributedString.Key: Any], ignoreParagraphStyles: Bool) { 115 | self.defaultName = defaultName 116 | self.attributes = attributes 117 | self.ignoreParagraphStyles = ignoreParagraphStyles 118 | } 119 | 120 | mutating func addAttributes(_ attributes: [NSAttributedString.Key: Any]?) { 121 | attributes?.forEach { (key, value) in 122 | self.attributes[key] = value 123 | } 124 | } 125 | 126 | mutating func parseOpenTag() -> String { 127 | guard !self.attributes.isEmpty else { return self.defaultName.openTag() } 128 | 129 | var styles: [String: String] = [:] 130 | var cocoaStyles: [String: String] = [:] 131 | var links = "" 132 | 133 | self.attributes.forEach { key, value in 134 | switch key { 135 | case .backgroundColor: 136 | guard let color = value as? Color else { return } 137 | 138 | styles["background-color"] = self.makeCSSrgba(for: color) 139 | case .foregroundColor: 140 | guard let color = value as? Color else { return } 141 | 142 | styles["color"] = self.makeCSSrgba(for: color) 143 | case .underlineStyle: 144 | guard let intValue = value as? Int else { return } 145 | guard let underlineStyle = Mappings.UnderlineStyle.encode[intValue] else { return } 146 | 147 | styles["text-decoration"] = (styles["text-decoration"] ?? "").stringByAppendingAttributeValue("underline") 148 | cocoaStyles["-cocoa-underline"] = underlineStyle 149 | case .underlineColor: 150 | guard let color = value as? Color else { return } 151 | 152 | cocoaStyles["-cocoa-underline-color"] = self.makeCSSrgba(for: color) 153 | case .strikethroughColor: 154 | guard let color = value as? Color else { return } 155 | 156 | cocoaStyles["-cocoa-strikethrough-color"] = self.makeCSSrgba(for: color) 157 | case .strikethroughStyle: 158 | guard let intValue = value as? Int else { return } 159 | guard let underlineStyle = Mappings.UnderlineStyle.encode[intValue] else { return } 160 | 161 | styles["text-decoration"] = "line-through".stringByAppendingAttributeValue(styles["text-decoration"]) 162 | cocoaStyles["-cocoa-strikethrough"] = underlineStyle 163 | case .font: 164 | guard let font = value as? Font else { return } 165 | 166 | 167 | let fontDescriptor = font.fontDescriptor 168 | 169 | var fontStyle = "" 170 | #if os(iOS) || (compiler(>=5.9) && os(visionOS)) 171 | if fontDescriptor.symbolicTraits.contains(.traitBold) { 172 | fontStyle += "bold " 173 | } 174 | if fontDescriptor.symbolicTraits.contains(.traitItalic) { 175 | fontStyle += "italic " 176 | } 177 | #elseif os(macOS) 178 | if fontDescriptor.symbolicTraits.contains(.bold) { 179 | fontStyle += "bold " 180 | } 181 | if fontDescriptor.symbolicTraits.contains(.italic) { 182 | fontStyle += "italic " 183 | } 184 | #endif 185 | 186 | if let fontFeatures = fontDescriptor.object(forKey: .featureSettings) as? [[String: Any]] { 187 | let features = fontFeatures.compactMap { feature in 188 | guard let typeID = feature[FontDescriptor.FeatureKey.cpTypeIdentifier.rawValue] else { return nil } 189 | guard let selectorID = feature[FontDescriptor.FeatureKey.selectorIdentifier.rawValue] else { return nil } 190 | 191 | return "\(typeID)/\(selectorID)" 192 | }.sorted(by: <).joined(separator: " ") 193 | 194 | if features.isEmpty == false { 195 | cocoaStyles["-cocoa-font-features"] = features 196 | } 197 | } 198 | 199 | fontStyle += String(format: "%gpx ", fontDescriptor.pointSize) 200 | fontStyle += "\"\(font.cpFamilyName)\"" 201 | 202 | styles["font"] = fontStyle 203 | cocoaStyles["-cocoa-font-postscriptname"] = "\"\(fontDescriptor.cpPostscriptName)\"" 204 | case .paragraphStyle: 205 | guard self.ignoreParagraphStyles == false else { return } 206 | guard let paragraphStyle = value as? NSParagraphStyle else { return } 207 | guard let alignment = Mappings.TextAlignment.encode[paragraphStyle.alignment] else { return } 208 | 209 | styles["text-align"] = alignment 210 | case .baselineOffset: 211 | guard let offset = value as? Float else { return } 212 | 213 | cocoaStyles["-cocoa-baseline-offset"] = String(format: "%.0f", offset) 214 | case NSAttributedString.Key(rawValue: "NSSuperScript"): 215 | guard let offset = value as? Int, offset != 0 else { return } 216 | 217 | let verticalAlignment = offset > 0 ? "super" : "sub" 218 | styles["vertical-align"] = verticalAlignment 219 | cocoaStyles["-cocoa-vertical-align"] = String(offset) 220 | case .link: 221 | let link: String 222 | switch value { 223 | case let urlString as String: 224 | link = urlString 225 | case let url as URL: 226 | link = url.absoluteString 227 | default: 228 | return 229 | } 230 | 231 | links = "href='\(link.htmlEscaped)'" 232 | self.hasParsedLinks = true 233 | default: 234 | break 235 | } 236 | } 237 | 238 | if styles.isEmpty && cocoaStyles.isEmpty && links.isEmpty { 239 | return self.defaultName.openTag() 240 | } 241 | 242 | var openTag = "" 243 | var styleAttributes = "" 244 | do { 245 | let separator = "; " 246 | let styleDictionaryTransform: ([String: String]) -> [String] = { return $0.sorted(by: <).map { "\($0): \($1)" } } 247 | let styleString = (styleDictionaryTransform(styles) + styleDictionaryTransform(cocoaStyles)).joined(separator: separator) 248 | if styleString.isEmpty == false { 249 | styleAttributes = "style='\(styleString)\(separator)'" 250 | } 251 | } 252 | 253 | if self.hasParsedLinks { 254 | if self.defaultName == .p { 255 | openTag += self.defaultName.openTag(with: styleAttributes) 256 | openTag += Name.a.openTag(with: links) 257 | } else { 258 | openTag += Name.a.openTag(with: styleAttributes, links) 259 | } 260 | } else { 261 | openTag += self.defaultName.openTag(with: styleAttributes) 262 | } 263 | 264 | return openTag 265 | } 266 | 267 | func makeCloseTag() -> String { 268 | if self.hasParsedLinks { 269 | if self.defaultName == .p { 270 | return Name.a.closeTag() + self.defaultName.closeTag() 271 | } else { 272 | return Name.a.closeTag() 273 | } 274 | } else { 275 | return self.defaultName.closeTag() 276 | } 277 | } 278 | 279 | // MARK: - Private 280 | 281 | private func compareStyleTags(tags: (tag1: String, tag2: String)) -> Bool { 282 | return tags.tag1 > tags.tag2 283 | } 284 | 285 | private func makeCSSrgba(for color: Color) -> String { 286 | var (red, green, blue): (CGFloat, CGFloat, CGFloat) 287 | let alpha = color.cgColor.alpha 288 | if color.cgColor.numberOfComponents == 2 { 289 | let monochromeValue = color.cgColor.components?[0] ?? 0 290 | (red, green, blue) = (monochromeValue, monochromeValue, monochromeValue) 291 | } else if color.cgColor.numberOfComponents == 4 { 292 | var cgColor = color.cgColor 293 | if let sRGBColorSpace = CGColorSpace(name: CGColorSpace.sRGB) { 294 | if let convertedCGColor = color.cgColor.converted(to: sRGBColorSpace, intent: .defaultIntent, options: nil) { 295 | cgColor = convertedCGColor 296 | } 297 | } 298 | red = cgColor.components?[0] ?? 0 299 | green = cgColor.components?[1] ?? 0 300 | blue = cgColor.components?[2] ?? 0 301 | } else { 302 | (red, green, blue) = (0, 0, 0) 303 | } 304 | 305 | let r = Int((red * 255.0).rounded()) 306 | let g = Int((green * 255.0).rounded()) 307 | let b = Int((blue * 255.0).rounded()) 308 | return "rgba(\(r), \(g), \(b), \(String(format: "%.6f", alpha)))" 309 | } 310 | } 311 | 312 | // MARK: - String 313 | 314 | private extension String { 315 | 316 | var htmlEscaped: String { 317 | guard self.contains(where: { Character.mapping[$0] != nil }) else { return self } 318 | 319 | return self.reduce("") { $0 + $1.escaped } 320 | } 321 | 322 | func stringByAppendingAttributeValue(_ value: String?) -> String { 323 | guard let value = value, value.isEmpty == false else { return self } 324 | 325 | if self.isEmpty { 326 | return value 327 | } else { 328 | return self + " " + value 329 | } 330 | } 331 | } 332 | 333 | private extension Character { 334 | 335 | static let mapping: [Character: String] = [ 336 | "&": "&", 337 | "\"": """, 338 | "'": "'", 339 | "<": "<", 340 | ">": ">", 341 | "\n": "
" 342 | ] 343 | 344 | var escaped: String { 345 | return Character.mapping[self] ?? String(self) 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 48; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | C8C931FC1FE81A6500938039 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C931FB1FE81A6500938039 /* AppDelegate.swift */; }; 11 | C8C931FE1FE81A6500938039 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8C931FD1FE81A6500938039 /* Assets.xcassets */; }; 12 | C8C932011FE81A6500938039 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = C8C931FF1FE81A6500938039 /* MainMenu.xib */; }; 13 | C8C932131FE81FE400938039 /* Ashton.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C8C9320F1FE81FD800938039 /* Ashton.framework */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXContainerItemProxy section */ 17 | C8C9320E1FE81FD800938039 /* PBXContainerItemProxy */ = { 18 | isa = PBXContainerItemProxy; 19 | containerPortal = C8C932091FE81FD800938039 /* Ashton.xcodeproj */; 20 | proxyType = 2; 21 | remoteGlobalIDString = C8A9CE001F6D13700095C6AE; 22 | remoteInfo = Ashton; 23 | }; 24 | C8C932101FE81FD800938039 /* PBXContainerItemProxy */ = { 25 | isa = PBXContainerItemProxy; 26 | containerPortal = C8C932091FE81FD800938039 /* Ashton.xcodeproj */; 27 | proxyType = 2; 28 | remoteGlobalIDString = C8A9CE091F6D13700095C6AE; 29 | remoteInfo = AshtonTests; 30 | }; 31 | /* End PBXContainerItemProxy section */ 32 | 33 | /* Begin PBXFileReference section */ 34 | C8C931F81FE81A6500938039 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 35 | C8C931FB1FE81A6500938039 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 36 | C8C931FD1FE81A6500938039 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 37 | C8C932001FE81A6500938039 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; 38 | C8C932021FE81A6500938039 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 39 | C8C932031FE81A6500938039 /* Example.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Example.entitlements; sourceTree = ""; }; 40 | C8C932091FE81FD800938039 /* Ashton.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Ashton.xcodeproj; path = ../Ashton.xcodeproj; sourceTree = ""; }; 41 | /* End PBXFileReference section */ 42 | 43 | /* Begin PBXFrameworksBuildPhase section */ 44 | C8C931F51FE81A6500938039 /* Frameworks */ = { 45 | isa = PBXFrameworksBuildPhase; 46 | buildActionMask = 2147483647; 47 | files = ( 48 | C8C932131FE81FE400938039 /* Ashton.framework in Frameworks */, 49 | ); 50 | runOnlyForDeploymentPostprocessing = 0; 51 | }; 52 | /* End PBXFrameworksBuildPhase section */ 53 | 54 | /* Begin PBXGroup section */ 55 | C8C931EF1FE81A6500938039 = { 56 | isa = PBXGroup; 57 | children = ( 58 | C8C931FA1FE81A6500938039 /* Example */, 59 | C8C931F91FE81A6500938039 /* Products */, 60 | C8C932091FE81FD800938039 /* Ashton.xcodeproj */, 61 | C8C932121FE81FE400938039 /* Frameworks */, 62 | ); 63 | sourceTree = ""; 64 | }; 65 | C8C931F91FE81A6500938039 /* Products */ = { 66 | isa = PBXGroup; 67 | children = ( 68 | C8C931F81FE81A6500938039 /* Example.app */, 69 | ); 70 | name = Products; 71 | sourceTree = ""; 72 | }; 73 | C8C931FA1FE81A6500938039 /* Example */ = { 74 | isa = PBXGroup; 75 | children = ( 76 | C8C931FB1FE81A6500938039 /* AppDelegate.swift */, 77 | C8C931FD1FE81A6500938039 /* Assets.xcassets */, 78 | C8C931FF1FE81A6500938039 /* MainMenu.xib */, 79 | C8C932021FE81A6500938039 /* Info.plist */, 80 | C8C932031FE81A6500938039 /* Example.entitlements */, 81 | ); 82 | path = Example; 83 | sourceTree = ""; 84 | }; 85 | C8C9320A1FE81FD800938039 /* Products */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | C8C9320F1FE81FD800938039 /* Ashton.framework */, 89 | C8C932111FE81FD800938039 /* AshtonTests.xctest */, 90 | ); 91 | name = Products; 92 | sourceTree = ""; 93 | }; 94 | C8C932121FE81FE400938039 /* Frameworks */ = { 95 | isa = PBXGroup; 96 | children = ( 97 | ); 98 | name = Frameworks; 99 | sourceTree = ""; 100 | }; 101 | /* End PBXGroup section */ 102 | 103 | /* Begin PBXNativeTarget section */ 104 | C8C931F71FE81A6500938039 /* Example */ = { 105 | isa = PBXNativeTarget; 106 | buildConfigurationList = C8C932061FE81A6500938039 /* Build configuration list for PBXNativeTarget "Example" */; 107 | buildPhases = ( 108 | C8C931F41FE81A6500938039 /* Sources */, 109 | C8C931F51FE81A6500938039 /* Frameworks */, 110 | C8C931F61FE81A6500938039 /* Resources */, 111 | ); 112 | buildRules = ( 113 | ); 114 | dependencies = ( 115 | ); 116 | name = Example; 117 | productName = Example; 118 | productReference = C8C931F81FE81A6500938039 /* Example.app */; 119 | productType = "com.apple.product-type.application"; 120 | }; 121 | /* End PBXNativeTarget section */ 122 | 123 | /* Begin PBXProject section */ 124 | C8C931F01FE81A6500938039 /* Project object */ = { 125 | isa = PBXProject; 126 | attributes = { 127 | LastSwiftUpdateCheck = 0920; 128 | LastUpgradeCheck = 1140; 129 | ORGANIZATIONNAME = "IdeasOnCanvas GmbH"; 130 | TargetAttributes = { 131 | C8C931F71FE81A6500938039 = { 132 | CreatedOnToolsVersion = 9.2; 133 | LastSwiftMigration = 1140; 134 | ProvisioningStyle = Automatic; 135 | }; 136 | }; 137 | }; 138 | buildConfigurationList = C8C931F31FE81A6500938039 /* Build configuration list for PBXProject "Example" */; 139 | compatibilityVersion = "Xcode 8.0"; 140 | developmentRegion = en; 141 | hasScannedForEncodings = 0; 142 | knownRegions = ( 143 | en, 144 | Base, 145 | ); 146 | mainGroup = C8C931EF1FE81A6500938039; 147 | productRefGroup = C8C931F91FE81A6500938039 /* Products */; 148 | projectDirPath = ""; 149 | projectReferences = ( 150 | { 151 | ProductGroup = C8C9320A1FE81FD800938039 /* Products */; 152 | ProjectRef = C8C932091FE81FD800938039 /* Ashton.xcodeproj */; 153 | }, 154 | ); 155 | projectRoot = ""; 156 | targets = ( 157 | C8C931F71FE81A6500938039 /* Example */, 158 | ); 159 | }; 160 | /* End PBXProject section */ 161 | 162 | /* Begin PBXReferenceProxy section */ 163 | C8C9320F1FE81FD800938039 /* Ashton.framework */ = { 164 | isa = PBXReferenceProxy; 165 | fileType = wrapper.framework; 166 | path = Ashton.framework; 167 | remoteRef = C8C9320E1FE81FD800938039 /* PBXContainerItemProxy */; 168 | sourceTree = BUILT_PRODUCTS_DIR; 169 | }; 170 | C8C932111FE81FD800938039 /* AshtonTests.xctest */ = { 171 | isa = PBXReferenceProxy; 172 | fileType = wrapper.cfbundle; 173 | path = AshtonTests.xctest; 174 | remoteRef = C8C932101FE81FD800938039 /* PBXContainerItemProxy */; 175 | sourceTree = BUILT_PRODUCTS_DIR; 176 | }; 177 | /* End PBXReferenceProxy section */ 178 | 179 | /* Begin PBXResourcesBuildPhase section */ 180 | C8C931F61FE81A6500938039 /* Resources */ = { 181 | isa = PBXResourcesBuildPhase; 182 | buildActionMask = 2147483647; 183 | files = ( 184 | C8C931FE1FE81A6500938039 /* Assets.xcassets in Resources */, 185 | C8C932011FE81A6500938039 /* MainMenu.xib in Resources */, 186 | ); 187 | runOnlyForDeploymentPostprocessing = 0; 188 | }; 189 | /* End PBXResourcesBuildPhase section */ 190 | 191 | /* Begin PBXSourcesBuildPhase section */ 192 | C8C931F41FE81A6500938039 /* Sources */ = { 193 | isa = PBXSourcesBuildPhase; 194 | buildActionMask = 2147483647; 195 | files = ( 196 | C8C931FC1FE81A6500938039 /* AppDelegate.swift in Sources */, 197 | ); 198 | runOnlyForDeploymentPostprocessing = 0; 199 | }; 200 | /* End PBXSourcesBuildPhase section */ 201 | 202 | /* Begin PBXVariantGroup section */ 203 | C8C931FF1FE81A6500938039 /* MainMenu.xib */ = { 204 | isa = PBXVariantGroup; 205 | children = ( 206 | C8C932001FE81A6500938039 /* Base */, 207 | ); 208 | name = MainMenu.xib; 209 | sourceTree = ""; 210 | }; 211 | /* End PBXVariantGroup section */ 212 | 213 | /* Begin XCBuildConfiguration section */ 214 | C8C932041FE81A6500938039 /* Debug */ = { 215 | isa = XCBuildConfiguration; 216 | buildSettings = { 217 | ALWAYS_SEARCH_USER_PATHS = NO; 218 | CLANG_ANALYZER_NONNULL = YES; 219 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 220 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 221 | CLANG_CXX_LIBRARY = "libc++"; 222 | CLANG_ENABLE_MODULES = YES; 223 | CLANG_ENABLE_OBJC_ARC = YES; 224 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 225 | CLANG_WARN_BOOL_CONVERSION = YES; 226 | CLANG_WARN_COMMA = YES; 227 | CLANG_WARN_CONSTANT_CONVERSION = YES; 228 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 229 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 230 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 231 | CLANG_WARN_EMPTY_BODY = YES; 232 | CLANG_WARN_ENUM_CONVERSION = YES; 233 | CLANG_WARN_INFINITE_RECURSION = YES; 234 | CLANG_WARN_INT_CONVERSION = YES; 235 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 236 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 237 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 238 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 239 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 240 | CLANG_WARN_STRICT_PROTOTYPES = YES; 241 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 242 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 243 | CLANG_WARN_UNREACHABLE_CODE = YES; 244 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 245 | CODE_SIGN_IDENTITY = "-"; 246 | COPY_PHASE_STRIP = NO; 247 | DEBUG_INFORMATION_FORMAT = dwarf; 248 | ENABLE_STRICT_OBJC_MSGSEND = YES; 249 | ENABLE_TESTABILITY = YES; 250 | GCC_C_LANGUAGE_STANDARD = gnu11; 251 | GCC_DYNAMIC_NO_PIC = NO; 252 | GCC_NO_COMMON_BLOCKS = YES; 253 | GCC_OPTIMIZATION_LEVEL = 0; 254 | GCC_PREPROCESSOR_DEFINITIONS = ( 255 | "DEBUG=1", 256 | "$(inherited)", 257 | ); 258 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 259 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 260 | GCC_WARN_UNDECLARED_SELECTOR = YES; 261 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 262 | GCC_WARN_UNUSED_FUNCTION = YES; 263 | GCC_WARN_UNUSED_VARIABLE = YES; 264 | MACOSX_DEPLOYMENT_TARGET = 10.13; 265 | MTL_ENABLE_DEBUG_INFO = YES; 266 | ONLY_ACTIVE_ARCH = YES; 267 | SDKROOT = macosx; 268 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 269 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 270 | }; 271 | name = Debug; 272 | }; 273 | C8C932051FE81A6500938039 /* Release */ = { 274 | isa = XCBuildConfiguration; 275 | buildSettings = { 276 | ALWAYS_SEARCH_USER_PATHS = NO; 277 | CLANG_ANALYZER_NONNULL = YES; 278 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 279 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 280 | CLANG_CXX_LIBRARY = "libc++"; 281 | CLANG_ENABLE_MODULES = YES; 282 | CLANG_ENABLE_OBJC_ARC = YES; 283 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 284 | CLANG_WARN_BOOL_CONVERSION = YES; 285 | CLANG_WARN_COMMA = YES; 286 | CLANG_WARN_CONSTANT_CONVERSION = YES; 287 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 288 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 289 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 290 | CLANG_WARN_EMPTY_BODY = YES; 291 | CLANG_WARN_ENUM_CONVERSION = YES; 292 | CLANG_WARN_INFINITE_RECURSION = YES; 293 | CLANG_WARN_INT_CONVERSION = YES; 294 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 295 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 296 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 297 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 298 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 299 | CLANG_WARN_STRICT_PROTOTYPES = YES; 300 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 301 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 302 | CLANG_WARN_UNREACHABLE_CODE = YES; 303 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 304 | CODE_SIGN_IDENTITY = "-"; 305 | COPY_PHASE_STRIP = NO; 306 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 307 | ENABLE_NS_ASSERTIONS = NO; 308 | ENABLE_STRICT_OBJC_MSGSEND = YES; 309 | GCC_C_LANGUAGE_STANDARD = gnu11; 310 | GCC_NO_COMMON_BLOCKS = YES; 311 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 312 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 313 | GCC_WARN_UNDECLARED_SELECTOR = YES; 314 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 315 | GCC_WARN_UNUSED_FUNCTION = YES; 316 | GCC_WARN_UNUSED_VARIABLE = YES; 317 | MACOSX_DEPLOYMENT_TARGET = 10.13; 318 | MTL_ENABLE_DEBUG_INFO = NO; 319 | SDKROOT = macosx; 320 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 321 | }; 322 | name = Release; 323 | }; 324 | C8C932071FE81A6500938039 /* Debug */ = { 325 | isa = XCBuildConfiguration; 326 | buildSettings = { 327 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 328 | CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; 329 | CODE_SIGN_IDENTITY = "-"; 330 | CODE_SIGN_STYLE = Automatic; 331 | COMBINE_HIDPI_IMAGES = YES; 332 | INFOPLIST_FILE = Example/Info.plist; 333 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; 334 | PRODUCT_BUNDLE_IDENTIFIER = com.ideasoncanvas.Example; 335 | PRODUCT_NAME = "$(TARGET_NAME)"; 336 | SWIFT_VERSION = 5.0; 337 | }; 338 | name = Debug; 339 | }; 340 | C8C932081FE81A6500938039 /* Release */ = { 341 | isa = XCBuildConfiguration; 342 | buildSettings = { 343 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 344 | CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; 345 | CODE_SIGN_IDENTITY = "-"; 346 | CODE_SIGN_STYLE = Automatic; 347 | COMBINE_HIDPI_IMAGES = YES; 348 | INFOPLIST_FILE = Example/Info.plist; 349 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; 350 | PRODUCT_BUNDLE_IDENTIFIER = com.ideasoncanvas.Example; 351 | PRODUCT_NAME = "$(TARGET_NAME)"; 352 | SWIFT_VERSION = 5.0; 353 | }; 354 | name = Release; 355 | }; 356 | /* End XCBuildConfiguration section */ 357 | 358 | /* Begin XCConfigurationList section */ 359 | C8C931F31FE81A6500938039 /* Build configuration list for PBXProject "Example" */ = { 360 | isa = XCConfigurationList; 361 | buildConfigurations = ( 362 | C8C932041FE81A6500938039 /* Debug */, 363 | C8C932051FE81A6500938039 /* Release */, 364 | ); 365 | defaultConfigurationIsVisible = 0; 366 | defaultConfigurationName = Release; 367 | }; 368 | C8C932061FE81A6500938039 /* Build configuration list for PBXNativeTarget "Example" */ = { 369 | isa = XCConfigurationList; 370 | buildConfigurations = ( 371 | C8C932071FE81A6500938039 /* Debug */, 372 | C8C932081FE81A6500938039 /* Release */, 373 | ); 374 | defaultConfigurationIsVisible = 0; 375 | defaultConfigurationName = Release; 376 | }; 377 | /* End XCConfigurationList section */ 378 | }; 379 | rootObject = C8C931F01FE81A6500938039 /* Project object */; 380 | } 381 | -------------------------------------------------------------------------------- /Sources/Ashton/AshtonXMLParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XMLParser.swift 3 | // Ashton 4 | // 5 | // Created by Michael Schwarz on 15.01.18. 6 | // Copyright © 2018 Michael Schwarz. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | #if os(iOS) || (compiler(>=5.9) && os(visionOS)) 11 | import UIKit 12 | #elseif os(macOS) 13 | import AppKit 14 | #endif 15 | 16 | 17 | protocol AshtonXMLParserDelegate: AnyObject { 18 | func didParseContent(_ parser: AshtonXMLParser, string: String) 19 | func didOpenTag(_ parser: AshtonXMLParser, name: AshtonXMLParser.Tag, attributes: [NSAttributedString.Key: Any]?) 20 | func didCloseTag(_ parser: AshtonXMLParser) 21 | func didEncounterUnknownFont(_ parser: AshtonXMLParser, fontName: String) 22 | } 23 | 24 | extension AshtonXMLParserDelegate { 25 | 26 | func didEncounterUnknownFont(_ parser: AshtonXMLParser, fontName: String) { } 27 | } 28 | 29 | 30 | /// Parses Ashton XML and returns parsed content and attributes 31 | final class AshtonXMLParser { 32 | 33 | typealias Hash = Int 34 | typealias StyleAttributesCache = Cache 35 | 36 | enum Tag { 37 | case p 38 | case span 39 | case strong 40 | case em 41 | case a 42 | case ignored 43 | } 44 | 45 | enum AttributeKeys { 46 | 47 | enum Style { 48 | static let backgroundColor = "background-color" 49 | static let color = "color" 50 | static let textDecoration = "text-decoration" 51 | static let font = "font" 52 | static let textAlign = "text-align" 53 | static let verticalAlign = "vertical-align" 54 | 55 | enum Cocoa { 56 | static let commonPrefix = "-cocoa-" 57 | static let strikethroughColor = "strikethrough-color" 58 | static let underlineColor = "underline-color" 59 | static let baseOffset = "baseline-offset" 60 | static let verticalAlign = "vertical-align" 61 | static let fontPostScriptName = "font-postscriptname" 62 | static let underline = "underline" 63 | static let strikethrough = "strikethrough" 64 | static let fontFeatures = "font-features" 65 | } 66 | } 67 | } 68 | 69 | // MARK: - Properties 70 | 71 | private(set) var fontBuilderCache: FontBuilder.FontCache 72 | private(set) var styleAttributesCache: StyleAttributesCache 73 | 74 | // MARK: - Lifecycle 75 | 76 | init(styleAttributesCache: StyleAttributesCache? = nil, fontBuilderCache: FontBuilder.FontCache? = nil) { 77 | self.styleAttributesCache = styleAttributesCache ?? .init() 78 | self.fontBuilderCache = fontBuilderCache ?? .init() 79 | } 80 | 81 | // MARK: - AshtonXMLParser 82 | 83 | weak var delegate: AshtonXMLParserDelegate? 84 | 85 | func parse(string: String) { 86 | var parsedScalars = "".unicodeScalars 87 | var iterator: String.UnicodeScalarView.Iterator = string.unicodeScalars.makeIterator() 88 | 89 | func flushContent() { 90 | guard parsedScalars.isEmpty == false else { return } 91 | 92 | delegate?.didParseContent(self, string: String(parsedScalars)) 93 | parsedScalars = "".unicodeScalars 94 | } 95 | 96 | while let character = iterator.next() { 97 | switch character { 98 | case "<": 99 | flushContent() 100 | self.parseTag(&iterator) 101 | case "&": 102 | if let escapedChar = iterator.parseEscapedChar() { 103 | parsedScalars.append(escapedChar) 104 | } else { 105 | parsedScalars.append(character) 106 | } 107 | default: 108 | parsedScalars.append(character) 109 | } 110 | } 111 | flushContent() 112 | } 113 | 114 | func clearStyleAttributesCache() { 115 | self.styleAttributesCache = .init() 116 | } 117 | 118 | func updateFontBuilderCache(_ cache: FontBuilder.FontCache) { 119 | self.fontBuilderCache = cache 120 | } 121 | } 122 | 123 | 124 | // MARK: - Private 125 | 126 | private extension AshtonXMLParser { 127 | 128 | // MARK: - Tag Parsing 129 | 130 | func parseTag(_ iterator: inout String.UnicodeScalarView.Iterator) { 131 | guard let nextCharacter = iterator.next(), nextCharacter != ">" else { return } 132 | 133 | switch nextCharacter { 134 | case "b": 135 | if iterator.forwardIfEquals("r /") { 136 | iterator.foward(untilAfter: ">") 137 | self.delegate?.didParseContent(self, string: "\n") 138 | } else { 139 | self.finalizeOpening(of: .ignored, iterator: &iterator) 140 | } 141 | case "p": 142 | self.finalizeOpening(of: .p, iterator: &iterator) 143 | case "s": 144 | let parsedTag: Tag 145 | if iterator.forwardIfEquals("pan") { 146 | parsedTag = .span 147 | } else if iterator.forwardIfEquals("trong") { 148 | parsedTag = .strong 149 | } else { 150 | parsedTag = .ignored 151 | } 152 | self.finalizeOpening(of: parsedTag, iterator: &iterator) 153 | case "e": 154 | let parsedTag: Tag = iterator.forwardIfEquals("m") ? .em : .ignored 155 | self.finalizeOpening(of: parsedTag, iterator: &iterator) 156 | case "a": 157 | self.finalizeOpening(of: .a, iterator: &iterator) 158 | case "/": 159 | iterator.foward(untilAfter: ">") 160 | self.delegate?.didCloseTag(self) 161 | default: 162 | self.finalizeOpening(of: .ignored, iterator: &iterator) 163 | } 164 | } 165 | 166 | func finalizeOpening(of tag: Tag, iterator: inout String.UnicodeScalarView.Iterator) { 167 | guard tag != .ignored else { 168 | iterator.foward(untilAfter: ">") 169 | self.delegate?.didOpenTag(self, name: .ignored, attributes: nil) 170 | return 171 | } 172 | guard let nextChar = iterator.next() else { return } 173 | 174 | switch nextChar { 175 | case ">": 176 | self.delegate?.didOpenTag(self, name: tag, attributes: nil) 177 | case " ": 178 | let attributes = self.parseAttributes(&iterator) 179 | self.delegate?.didOpenTag(self, name: tag, attributes: attributes) 180 | default: 181 | iterator.foward(untilAfter: ">") 182 | // it seems we parsed only a prefix and the actual tag is unknown 183 | self.delegate?.didOpenTag(self, name: .ignored, attributes: nil) 184 | } 185 | } 186 | 187 | // MARK: - Attribute Parsing 188 | 189 | func parseAttributes(_ iterator: inout String.UnicodeScalarView.Iterator) -> [NSAttributedString.Key: Any]? { 190 | var parsedAttributes: [NSAttributedString.Key: Any]? = nil 191 | 192 | func addAttributes(_ attributes: [NSAttributedString.Key: Any]) { 193 | if parsedAttributes == nil { 194 | parsedAttributes = attributes 195 | } else { 196 | parsedAttributes?.merge(attributes) { return $1 } 197 | } 198 | } 199 | 200 | while let nextChar = iterator.next(), nextChar != ">" { 201 | switch nextChar { 202 | case "s": 203 | guard iterator.forwardAndCheckingIfEquals("tyle") else { break } 204 | 205 | iterator.foward(untilAfter: "=") 206 | addAttributes(self.parseStyles(&iterator)) 207 | case "h": 208 | guard iterator.forwardAndCheckingIfEquals("ref") else { break } 209 | 210 | iterator.foward(untilAfter: "=") 211 | addAttributes(self.parseHREF(&iterator)) 212 | default: 213 | break 214 | } 215 | } 216 | return parsedAttributes 217 | } 218 | 219 | // MARK: - Style Parsing 220 | 221 | func parseStyles(_ iterator: inout String.UnicodeScalarView.Iterator) -> [NSAttributedString.Key: Any] { 222 | var attributes: [NSAttributedString.Key: Any] = [:] 223 | 224 | var fontBuilder: FontBuilder? 225 | func ensureFontBuilder() -> FontBuilder { 226 | if let fontBuilder = fontBuilder { 227 | return fontBuilder 228 | } 229 | let newFontBuilder = FontBuilder(fontCache: self.fontBuilderCache) 230 | fontBuilder = newFontBuilder 231 | return newFontBuilder 232 | } 233 | 234 | iterator.skipWhiteSpace() 235 | guard let terminationCharacter = iterator.next(), terminationCharacter == "'" || terminationCharacter == "\"" else { return attributes } 236 | 237 | let cacheKey = iterator.hash(until: terminationCharacter) 238 | if let cachedAttributes = self.styleAttributesCache[cacheKey] { 239 | iterator.foward(untilAfter: terminationCharacter) 240 | 241 | return cachedAttributes 242 | } 243 | 244 | while let char = iterator.testNextCharacter(), char != terminationCharacter, char != ">" { 245 | switch char { 246 | case "b": 247 | guard iterator.forwardIfEquals(AttributeKeys.Style.backgroundColor) else { break } 248 | 249 | iterator.skipStyleAttributeIgnoredCharacters() 250 | attributes[.backgroundColor] = iterator.parseColor() 251 | case "c": 252 | guard iterator.forwardIfEquals(AttributeKeys.Style.color) else { break } 253 | 254 | iterator.skipStyleAttributeIgnoredCharacters() 255 | attributes[.foregroundColor] = iterator.parseColor() 256 | case "t": 257 | if iterator.forwardIfEquals(AttributeKeys.Style.textAlign) { 258 | iterator.skipStyleAttributeIgnoredCharacters() 259 | guard let textAlignment = iterator.parseTextAlignment() else { break } 260 | 261 | let paragraphStyle = NSMutableParagraphStyle() 262 | paragraphStyle.alignment = textAlignment 263 | attributes[.paragraphStyle] = paragraphStyle 264 | } else if iterator.forwardIfEquals(AttributeKeys.Style.textDecoration) { 265 | iterator.skipStyleAttributeIgnoredCharacters() 266 | // we have to use a temporary iterator to ensure that we are not skipping attributes too far 267 | var tempIterator = iterator 268 | while let textDecoration = tempIterator.parseTextDecoration() { 269 | attributes[textDecoration] = NSUnderlineStyle.single.rawValue 270 | iterator = tempIterator 271 | tempIterator.skipStyleAttributeIgnoredCharacters() 272 | } 273 | } 274 | case "f": 275 | guard iterator.forwardIfEquals(AttributeKeys.Style.font) else { break } 276 | 277 | iterator.skipStyleAttributeIgnoredCharacters() 278 | 279 | let fontAttributes = iterator.parseFontAttributes() 280 | let fontBuilder = ensureFontBuilder() 281 | fontBuilder.isBold = fontAttributes.isBold 282 | fontBuilder.isItalic = fontAttributes.isItalic 283 | fontBuilder.familyName = fontAttributes.family 284 | fontBuilder.pointSize = fontAttributes.points 285 | case "v": 286 | guard iterator.forwardIfEquals(AttributeKeys.Style.verticalAlign) else { break } 287 | 288 | iterator.skipStyleAttributeIgnoredCharacters() 289 | guard attributes[.superscript] == nil else { break } 290 | guard let alignment = iterator.parseVerticalAlignmentFromString() else { break } 291 | 292 | attributes[.superscript] = alignment 293 | case "-": 294 | guard iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.commonPrefix) else { break } 295 | guard let firstChar = iterator.testNextCharacter() else { break } 296 | 297 | switch firstChar { 298 | case "s": 299 | if iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.strikethroughColor) { 300 | iterator.skipStyleAttributeIgnoredCharacters() 301 | attributes[.strikethroughColor] = iterator.parseColor() 302 | } else if iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.strikethrough) { 303 | iterator.skipStyleAttributeIgnoredCharacters() 304 | guard let underlineStyle = iterator.parseUnderlineStyle() else { break } 305 | 306 | attributes[.strikethroughStyle] = underlineStyle.rawValue 307 | } 308 | case "u": 309 | if iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.underlineColor) { 310 | iterator.skipStyleAttributeIgnoredCharacters() 311 | attributes[.underlineColor] = iterator.parseColor() 312 | } else if iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.underline) { 313 | iterator.skipStyleAttributeIgnoredCharacters() 314 | guard let underlineStyle = iterator.parseUnderlineStyle() else { break } 315 | 316 | attributes[.underlineStyle] = underlineStyle.rawValue 317 | } 318 | case "b": 319 | guard iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.baseOffset) else { break } 320 | iterator.skipStyleAttributeIgnoredCharacters() 321 | guard let baselineOffset = iterator.parseBaselineOffset() else { break } 322 | 323 | attributes[.baselineOffset] = baselineOffset 324 | case "v": 325 | guard iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.verticalAlign) else { break } 326 | 327 | iterator.skipStyleAttributeIgnoredCharacters() 328 | guard let verticalAlignment = iterator.parseVerticalAlignment() else { break } 329 | 330 | attributes[.superscript] = verticalAlignment 331 | case "f": 332 | if iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.fontPostScriptName) { 333 | iterator.skipStyleAttributeIgnoredCharacters() 334 | guard let postscriptName = iterator.parsePostscriptFontName() else { break } 335 | 336 | let fontBuilder = ensureFontBuilder() 337 | fontBuilder.postScriptName = postscriptName 338 | } else if iterator.forwardIfEquals(AttributeKeys.Style.Cocoa.fontFeatures) { 339 | iterator.skipStyleAttributeIgnoredCharacters() 340 | let fontFeatures = iterator.parseFontFeatures() 341 | guard fontFeatures.isEmpty == false else { break } 342 | 343 | let fontBuilder = ensureFontBuilder() 344 | fontBuilder.fontFeatures = fontFeatures 345 | } 346 | default: 347 | break 348 | } 349 | default: 350 | break 351 | } 352 | guard iterator.forwardUntilNextAttribute(terminationChar: terminationCharacter) else { break } 353 | } 354 | 355 | iterator.foward(untilAfter: terminationCharacter) 356 | 357 | if let font = fontBuilder?.makeFont() { 358 | attributes[.font] = font 359 | // Core Text implicitly returns a fallback font if the the requested font descriptor 360 | // does not lead to an exact match. We perform a simple heuristic to take note of such 361 | // fallbacks and inform the delegate. 362 | if let requestedFontName = fontBuilder?.fontName, font.fontName != requestedFontName { 363 | self.delegate?.didEncounterUnknownFont(self, fontName: requestedFontName) 364 | } 365 | } 366 | 367 | self.styleAttributesCache[cacheKey] = attributes 368 | return attributes 369 | } 370 | 371 | // MARK: - href-Parsing 372 | 373 | func parseHREF(_ iterator: inout String.UnicodeScalarView.Iterator) -> [NSAttributedString.Key: Any] { 374 | iterator.skipStyleAttributeIgnoredCharacters() 375 | guard let url = iterator.parseURL() else { return [:] } 376 | 377 | return [.link: url] 378 | } 379 | } 380 | --------------------------------------------------------------------------------