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 | [](https://swift.org/package-manager/)
4 | [](https://github.com/Carthage/Carthage)
5 | 
6 | 
7 | [](LICENSE.md)
8 | [](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 | 
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("\nThese 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("\nNode 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:@""];
145 | [tag appendString:[self tagNameForAttributes:attrs]];
146 | [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 "\(self.rawValue)>"
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 |
--------------------------------------------------------------------------------