diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml
index 76851ba..1841e75 100644
--- a/.github/workflows/swift.yml
+++ b/.github/workflows/swift.yml
@@ -18,5 +18,5 @@ jobs:
- uses: actions/checkout@v3
- name: Build
run: swift build -v
- - name: Run tests
- run: swift test -v
+ - name: Run tests with release configurations
+ run: swift test -v -c release
diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/599589E8-9A65-4A13-A7CA-EA286CFECAE6.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/599589E8-9A65-4A13-A7CA-EA286CFECAE6.plist
new file mode 100644
index 0000000..0e904cb
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/599589E8-9A65-4A13-A7CA-EA286CFECAE6.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ classNames
+
+ MeasurementTests
+
+ testPerformance()
+
+ com.apple.dt.XCTMetric_Memory.physical
+
+ baselineAverage
+ 13.107200
+ baselineIntegrationDisplayName
+ Local Baseline
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/99F2CE76-E54F-4D94-A4E3-79239E11F46F.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/99F2CE76-E54F-4D94-A4E3-79239E11F46F.plist
new file mode 100644
index 0000000..4db60e6
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/99F2CE76-E54F-4D94-A4E3-79239E11F46F.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ classNames
+
+ MeasurementTests
+
+ testPerformance()
+
+ com.apple.dt.XCTMetric_CPU.cycles
+
+ baselineAverage
+ 3585.980600
+ baselineIntegrationDisplayName
+ Local Baseline
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/Info.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/Info.plist
new file mode 100644
index 0000000..e6657ee
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcbaselines/PersianJustifyTests.xcbaseline/Info.plist
@@ -0,0 +1,64 @@
+
+
+
+
+ runDestinationsByUUID
+
+ 599589E8-9A65-4A13-A7CA-EA286CFECAE6
+
+ localComputer
+
+ busSpeedInMHz
+ 0
+ cpuCount
+ 1
+ cpuKind
+ Apple M1 Pro
+ cpuSpeedInMHz
+ 0
+ logicalCPUCoresPerPackage
+ 10
+ modelCode
+ MacBookPro18,1
+ physicalCPUCoresPerPackage
+ 10
+ platformIdentifier
+ com.apple.platform.macosx
+
+ targetArchitecture
+ arm64e
+
+ 99F2CE76-E54F-4D94-A4E3-79239E11F46F
+
+ localComputer
+
+ busSpeedInMHz
+ 0
+ cpuCount
+ 1
+ cpuKind
+ Apple M1 Pro
+ cpuSpeedInMHz
+ 0
+ logicalCPUCoresPerPackage
+ 10
+ modelCode
+ MacBookPro18,1
+ physicalCPUCoresPerPackage
+ 10
+ platformIdentifier
+ com.apple.platform.macosx
+
+ targetArchitecture
+ arm64
+ targetDevice
+
+ modelCode
+ iPhone16,1
+ platformIdentifier
+ com.apple.platform.iphonesimulator
+
+
+
+
+
diff --git a/Example/perian_justify_example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/perian_justify_example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 8c5a2de..cd3cf99 100644
--- a/Example/perian_justify_example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Example/perian_justify_example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "a3510e27242cfd4d5e1fce3ce0f52d9f9523f6630c83c36dbfd5fde858554755",
+ "originHash" : "fbd9878878133309570b047b11f57a737e995fb2bc7eba51a820ab14bf03a929",
"pins" : [
{
"identity" : "persianjustify",
diff --git a/Package.swift b/Package.swift
index 0720712..00d280e 100644
--- a/Package.swift
+++ b/Package.swift
@@ -6,20 +6,20 @@ import PackageDescription
let package = Package(
name: "PersianJustify",
products: [
- // Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "PersianJustify",
- targets: ["PersianJustify"]),
+ targets: ["PersianJustify"]
+ ),
],
dependencies: [
.package(url: "https://github.com/ArtSabintsev/FontBlaster", from: "5.3.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.15.4"),
],
targets: [
- // Targets are the basic building blocks of a package, defining a module or a test suite.
- // Targets can depend on other targets in this package and products from dependencies.
.target(
- name: "PersianJustify"),
+ name: "PersianJustify",
+ path: "Sources"
+ ),
.testTarget(
name: "PersianJustifyTests",
dependencies: [
diff --git a/Sources/ArabicCharacterEvaluator.swift b/Sources/ArabicCharacterEvaluator.swift
new file mode 100644
index 0000000..8cc5047
--- /dev/null
+++ b/Sources/ArabicCharacterEvaluator.swift
@@ -0,0 +1,17 @@
+import class Foundation.NSPredicate
+
+/// Regex that will be used against an arbitrary string,
+/// to identify if it's in Arabic.
+private let arabicRegex = "(?s).*\\p{Arabic}.*"
+
+/// Wrapper around a predicate that will encapsulate the logic of identifying if a given string is in Arabic.
+struct ArabicCharacterEvaluator {
+ private static let _predicate = NSPredicate(format: "SELF MATCHES %@", arabicRegex)
+
+ func evaluate(with character: Character) -> Bool {
+ // Character must be converted to string before being fed to predicate.
+ let stringRepresentation = String(character)
+
+ return Self._predicate.evaluate(with: stringRepresentation)
+ }
+}
diff --git a/Sources/Characters/ExtenderCharacter.swift b/Sources/Characters/ExtenderCharacter.swift
new file mode 100644
index 0000000..9137ec2
--- /dev/null
+++ b/Sources/Characters/ExtenderCharacter.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+/// Wrapper around a character which is used to extend some characters in `Farsi`.
+struct ExtenderCharacter {
+ private static let _character: Character = "ـ" // Persian underline
+
+ static var stringRepresentation: String {
+ String(Self._character)
+ }
+
+ static var wordRepresentation: Word {
+ Word(stringRepresentation)
+ }
+}
diff --git a/Sources/Characters/LineBreakCharacter.swift b/Sources/Characters/LineBreakCharacter.swift
new file mode 100644
index 0000000..8057b96
--- /dev/null
+++ b/Sources/Characters/LineBreakCharacter.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+/// Wrapper around a character which is used to break a line.
+struct LineBreakCharacter {
+ fileprivate static let _character: Character = "\n"
+
+ static var stringRepresentation: String {
+ String(LineBreakCharacter._character)
+ }
+
+ static var attributedStringRepresentation: NSAttributedString {
+ NSAttributedString(string: stringRepresentation)
+ }
+
+ static func + (lhs: LineBreakCharacter, rhs: LineBreakCharacter) -> String {
+ stringRepresentation + stringRepresentation
+ }
+
+ static func + (lhs: String, rhs: LineBreakCharacter) -> String {
+ lhs + stringRepresentation
+ }
+}
+
+extension String {
+ func replacingOccurrences(of target: String, with replacement: LineBreakCharacter) -> String {
+ replacingOccurrences(of: target, with: LineBreakCharacter.stringRepresentation)
+ }
+
+ func splitWithLineSeparator() -> [Substring] {
+ split(separator: LineBreakCharacter._character)
+ }
+}
diff --git a/Sources/Characters/MiniSpaceCharacter.swift b/Sources/Characters/MiniSpaceCharacter.swift
new file mode 100644
index 0000000..56d786c
--- /dev/null
+++ b/Sources/Characters/MiniSpaceCharacter.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+/// Wrapper around a character which is used to add a small spacer between characters in `Farsi`.
+struct MiniSpaceCharacter {
+ private static let _character: Character = ""
+
+ static var stringRepresentation: String {
+ String(Self._character)
+ }
+
+ static func + (lhs: MiniSpaceCharacter, rhs: MiniSpaceCharacter) -> String {
+ stringRepresentation + stringRepresentation
+ }
+
+ static func + (lhs: String, rhs: MiniSpaceCharacter) -> String {
+ lhs + stringRepresentation
+ }
+}
diff --git a/Sources/Characters/SpaceCharacter.swift b/Sources/Characters/SpaceCharacter.swift
new file mode 100644
index 0000000..3639667
--- /dev/null
+++ b/Sources/Characters/SpaceCharacter.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+/// Wrapper around a character which is used to append a space.
+struct SpaceCharacter {
+ fileprivate static let _character: Character = " "
+
+ static var stringRepresentation: String {
+ String(Self._character)
+ }
+
+ static var attributedStringRepresentation: NSAttributedString {
+ NSAttributedString(string: stringRepresentation)
+ }
+
+ static func + (lhs: String, rhs: SpaceCharacter) -> String {
+ lhs + stringRepresentation
+ }
+}
+
+extension NSMutableAttributedString {
+ func appendSpaceCharacter() {
+ append(SpaceCharacter.attributedStringRepresentation)
+ }
+}
+
+extension String.SubSequence {
+ func splitWithSpaceSeparator() -> [Substring] {
+ split(separator: SpaceCharacter._character)
+ }
+}
diff --git a/Sources/CoreTextExtensible/Line.swift b/Sources/CoreTextExtensible/Line.swift
new file mode 100644
index 0000000..8c55071
--- /dev/null
+++ b/Sources/CoreTextExtensible/Line.swift
@@ -0,0 +1,127 @@
+import Foundation
+
+#if canImport(UIKit)
+import class UIKit.NSMutableParagraphStyle
+import enum UIKit.NSTextAlignment
+#elseif canImport(AppKit)
+import class AppKit.NSMutableParagraphStyle
+import enum AppKit.NSTextAlignment
+#endif
+
+/// Wrapper around a line.
+/// Object will extend line capabilities and encapsulate logics related to justifying a line.
+struct Line {
+ private let _line: String.SubSequence
+
+ init(_ line: String.SubSequence) {
+ self._line = line
+ }
+
+ init(_ line: String) {
+ self._line = String.SubSequence(line)
+ }
+
+ var stringRepresentation: String {
+ String(_line)
+ }
+
+ /// Get every word in a line.
+ func getWords() -> [Word] {
+ _line.splitWithSpaceSeparator()
+ .map { Word($0) }
+ }
+
+ /// Re-aligns a line based on proposed width and given font.
+ func justify(
+ in proposedWidth: CGFloat,
+ isLastLineInParagraph: Bool,
+ font: Font
+ ) -> NSMutableAttributedString {
+ let words = getWords()
+
+ lazy var emptySpace: CGFloat = {
+ let totalWordsWidth = words.getRequiredWidth(with: font)
+
+ return proposedWidth - totalWordsWidth
+ }()
+
+ lazy var requiredExtender: CGFloat = {
+ let singleExtenderWidth = ExtenderCharacter
+ .wordRepresentation
+ .getWordWidth(font: font, isRequiredSpace: false)
+
+ let extractedExpr = emptySpace / singleExtenderWidth
+ return Swift.max(extractedExpr, 0)
+ }()
+
+ let supportedExtenderWords = words.filter { $0.canSupportExtender }
+
+ if isLastLineInParagraph {
+ // May not required justify.
+ return NSMutableAttributedString(string: stringRepresentation)
+ } else {
+ lazy var isManyExtendersRequired = CGFloat(supportedExtenderWords.count) < requiredExtender
+
+ let requiredExtend: CGFloat
+
+ if isManyExtendersRequired {
+ requiredExtend = emptySpace / CGFloat(supportedExtenderWords.count)
+ } else if requiredExtender > 0 && supportedExtenderWords.count > 0 {
+ requiredExtend = max(requiredExtender * 0.1, 0)
+ } else {
+ return NSMutableAttributedString(string: stringRepresentation)
+ }
+
+ return getExtendedWords(
+ words: supportedExtenderWords,
+ requiredExtend: requiredExtend * 0.2,
+ font: font
+ )
+ }
+ }
+
+ private func getExtendedWords(
+ words: [Word],
+ requiredExtend: CGFloat,
+ font: Font
+ ) -> NSMutableAttributedString {
+ let style: NSMutableParagraphStyle = {
+ let style = NSMutableParagraphStyle()
+ style.alignment = NSTextAlignment.justified
+ style.baseWritingDirection = .rightToLeft
+ return style
+ }()
+
+ let totalRange = NSRange(location: 0, length: _line.utf16.count)
+
+ let attributedText = NSMutableAttributedString(string: stringRepresentation)
+ attributedText.setAttributes([NSAttributedString.Key.font: font], range: totalRange)
+
+ for word in words {
+ let range = getRange(of: word)
+ attributedText.addAttribute(NSAttributedString.Key.kern, value: requiredExtend, range: range)
+ attributedText.addAttributes([NSAttributedString.Key.paragraphStyle: style], range: range)
+ }
+
+ return attributedText
+ }
+
+ private func getRange(of word: Word) -> NSRange {
+ (_line as NSString).range(of: word.stringRepresentation, options: .widthInsensitive)
+ }
+}
+
+extension [Word] {
+ /// Method that will determine if a given word can fit inside the line based on proposed width.
+ func hasRoomForNextWord(nextWord: Word, proposedWidth: CGFloat, font: Font) -> Bool {
+ let requiredWidth = nextWord.getWordWidth(font: font)
+ let currentWidth = getRequiredWidth(with: font)
+ return (currentWidth + requiredWidth) <= proposedWidth
+ }
+
+ /// Method that will calculate required width for words.
+ fileprivate func getRequiredWidth(with font: Font) -> CGFloat {
+ map { $0.getWordWidth(font: font) }
+ .reduce(0, +)
+ }
+}
diff --git a/Sources/CoreTextExtensible/Word.swift b/Sources/CoreTextExtensible/Word.swift
new file mode 100644
index 0000000..c3ab022
--- /dev/null
+++ b/Sources/CoreTextExtensible/Word.swift
@@ -0,0 +1,85 @@
+import Foundation
+import func CoreText.CTLineCreateWithAttributedString
+import func CoreText.CTLineGetTypographicBounds
+
+/// Character that should not be extended in any circumstances in `Farsi`.
+private let forbiddenExtendableCharacters = ["ا", "د", "ذ", "ر", "ز", "و", "آ", "ژ"]
+
+/// Wrapper around a word.
+struct Word {
+ fileprivate let _word: Substring
+
+ init(_ word: Substring) {
+ self._word = word
+ }
+
+ init(_ word: String) {
+ self._word = Substring(word)
+ }
+
+ var stringRepresentation: String {
+ String(_word)
+ }
+
+ /// Flag that determines if a word can support extending.
+ /// It's common in `Farsi` for a word to be extended in an arbitrary font.
+ var canSupportExtender: Bool {
+ let count = _word.count
+
+ let isWordEmpty = !_word.lazy.dropFirst().isEmpty
+ guard isWordEmpty else {
+ return false
+ }
+
+ let characters = Array(_word)
+
+ // @Sajad double check where condition
+ for i in stride(from: count-1, to: 0, by: -1) where (i > 0) && i < count {
+ let char = characters[i]
+ let rightChar = characters[i-1]
+
+ let isNotForbiddenCharacter = !forbiddenExtendableCharacters.contains(String(rightChar))
+ let isNextCharArabic = rightChar.isArabic
+ let isCharArabic = char.isArabic
+
+ if isNotForbiddenCharacter && isNextCharArabic && isCharArabic {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ /// Calculate word width based on font.
+ func getWordWidth(font: Font, isRequiredSpace: Bool = true) -> CGFloat {
+ let wordStringRepresentation = stringRepresentation
+ let text = isRequiredSpace ? (wordStringRepresentation + SpaceCharacter()) : wordStringRepresentation
+
+ let attributedString = NSAttributedString(string: text, attributes: [.font: font])
+
+ let line = CTLineCreateWithAttributedString(attributedString)
+ var ascent: CGFloat = 0
+ var descent: CGFloat = 0
+ var leading: CGFloat = 0
+
+ let width = CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
+ return CGFloat(width)
+ }
+}
+
+extension String.Element {
+ fileprivate var isArabic: Bool {
+ let evaluator = ArabicCharacterEvaluator()
+ return evaluator.evaluate(with: self)
+ }
+}
+
+extension [Word] {
+ /// Re-create a line from words.
+ func createLineFromWords() -> Line {
+ let joinedWords = map(\._word)
+ .joined(separator: SpaceCharacter.stringRepresentation)
+
+ return Line(joinedWords)
+ }
+}
diff --git a/Sources/PersianJustify/Extensions/String.range(of,options).swift b/Sources/PersianJustify/Extensions/String.range(of,options).swift
deleted file mode 100644
index c003fb7..0000000
--- a/Sources/PersianJustify/Extensions/String.range(of,options).swift
+++ /dev/null
@@ -1,9 +0,0 @@
-import Foundation
-
-extension String {
-
- /// Finds and returns the range of the first occurrence of a given string within the string, subject to given options.
- func range(of searchString: String, options mask: NSString.CompareOptions = []) -> NSRange {
- (self as NSString).range(of: searchString, options: mask)
- }
-}
diff --git a/Sources/PersianJustify/Extensions/String.toPJString(in).swift b/Sources/PersianJustify/Extensions/String.toPJString(in).swift
deleted file mode 100644
index 5fcf71e..0000000
--- a/Sources/PersianJustify/Extensions/String.toPJString(in).swift
+++ /dev/null
@@ -1,17 +0,0 @@
-import Foundation
-
-extension String {
-
- @available(
- *, deprecated,
- renamed: "toPJString(fittingWidth:font:)",
- message: "This method requires too much information and will not be available from v1.0"
- )
- public func toPJString(in view: View) -> NSAttributedString {
- let defaultFont = Font()
- let font = view.getFont() ?? defaultFont
- let parentWidth = view.frame.width
-
- return toPJString(fittingWidth: parentWidth, font: font)
- }
-}
diff --git a/Sources/PersianJustify/Extensions/View.getFont.swift b/Sources/PersianJustify/Extensions/View.getFont.swift
deleted file mode 100644
index 6756cd9..0000000
--- a/Sources/PersianJustify/Extensions/View.getFont.swift
+++ /dev/null
@@ -1,9 +0,0 @@
-import Foundation
-
-internal extension View {
- func getFont() -> Font? {
- let key = "font"
- guard responds(to: Selector(key)) else { return nil }
- return value(forKey: key) as? Font
- }
-}
diff --git a/Sources/PersianJustify/PersianJustify.swift b/Sources/PersianJustify/PersianJustify.swift
index 30aac46..390e3f5 100644
--- a/Sources/PersianJustify/PersianJustify.swift
+++ b/Sources/PersianJustify/PersianJustify.swift
@@ -11,150 +11,173 @@ import UIKit
import AppKit
#endif
-import CoreText
-
-// MARK: - Variables
-fileprivate let nextLineCharacter: Character = "\n"
-fileprivate let spaceCharacter: Character = " "
-fileprivate let miniSpaceCharacter: Character = ""
-fileprivate let extenderCharacter: Character = "ـ" // Persian underline
-fileprivate let attributedSpace = NSAttributedString(string: spaceCharacter.description)
-fileprivate let attributedNextLine = NSMutableAttributedString(string: nextLineCharacter.description)
-fileprivate let forbiddenExtendableCharacters = ["ا", "د", "ذ", "ر", "ز", "و", "آ", "ژ"]
-
-// MARK: - Usage using toPJString function
extension String {
-
- public func toPJString(fittingWidth parentWidth: CGFloat, font: Font = Font()) -> NSAttributedString {
- let defaultAttributedTest = NSAttributedString(string: self)
- // return defaultAttributedTest // MARK: Uncomment to see the unjustified text
- if isEmpty { return defaultAttributedTest }
- let final = NSMutableAttributedString(string: "")
- let doubleNextLine = nextLineCharacter.description + nextLineCharacter.description
- let allLines = replacingOccurrences(of:doubleNextLine, with: nextLineCharacter.description).getWords(separator: nextLineCharacter)
- for i in 0.. NSAttributedString {
+ guard !isEmpty else {
+ return NSAttributedString()
}
- return final
+
+ let lines = splitStringToLines()
+
+ return justify(lines, in: proposedWidth, with: font)
}
}
-// MARK: - Private Functions
-private extension String {
-
- func getJustifiedLine(in parentWidth: CGFloat, isLastLineInParagraph: Bool, font: Font) -> NSMutableAttributedString {
- let words = getWords(separator: spaceCharacter)
- let totalWordsWidth = words.compactMap({$0.getWordWidth(font: font)}).reduce(0, +)
- let emptySpace = parentWidth - totalWordsWidth
- let singleExtenderWidth = extenderCharacter.description.getWordWidth(font: font, isRequiredSpace: false)
- let requiredExtender = Swift.max((emptySpace / singleExtenderWidth), 0)
- let supportedExtenderWords = words.filter({$0.isSupportExtender()})
- print("words: ", self)
- print("parent width: \(parentWidth)")
- print("totalWordsWidth \(totalWordsWidth)")
- print("emptySpace: \(emptySpace)")
- if isLastLineInParagraph { // MARK: May not required justify.
- return attributedStringWithFont(font: font)
- } else {
- let isManyExtendersRequired = CGFloat(supportedExtenderWords.count) < requiredExtender
- if isManyExtendersRequired {
- print("many extenders required")
- let requiredExtend = emptySpace / CGFloat(supportedExtenderWords.count)
- return getExtendedWords(words: supportedExtenderWords, requiredExtend: requiredExtend * 0.2, font: font)
- } else if requiredExtender > 0 && supportedExtenderWords.count > 0 {
- print("little extenders required")
- return getExtendedWords(words: supportedExtenderWords, requiredExtend: max(requiredExtender * 0.1, 0), font: font)
- } else {
- print("no extender added")
- return attributedStringWithFont(font: font)
- }
+#if canImport(UIKit)
+extension Label {
+ /// Method that will layouts text in a `Farsi` calligraphy friendly way.
+ /// - Warning: This is a computed heavy operation.
+ public func toPJString(idealWidth: CGFloat? = nil) throws {
+ guard let text else {
+ throw PersianJustifyError.getTextFailure(self)
}
- }
-
- func getExtendedWords(words: [String], requiredExtend: CGFloat, font: Font) -> NSMutableAttributedString {
- print("------------------------------------------")
- let style = NSMutableParagraphStyle()
- style.alignment = NSTextAlignment.justified
- style.baseWritingDirection = .rightToLeft
- let attributedText = attributedStringWithFont(font: font)
- for word in words {
- let range = range(of: word, options: .widthInsensitive)
- attributedText.addAttribute(NSAttributedString.Key.kern, value: requiredExtend, range: range)
- attributedText.addAttributes([NSAttributedString.Key.paragraphStyle: style], range: range)
- print("applying extend | \(requiredExtend) to \(word)")
+
+ guard let font else {
+ throw PersianJustifyError.getFontFailure(self)
}
- print("------------------------------------------")
- return attributedText
- }
- func attributedStringWithFont(font: Font)-> NSMutableAttributedString {
- let totalRange = NSRange(location: 0, length: self.utf16.count)
- let attributedText = NSMutableAttributedString(string: self)
- attributedText.setAttributes([NSAttributedString.Key.font: font], range: totalRange)
- return attributedText
+ let proposedWidth = idealWidth ?? frame.width
+
+ let realignedText = text.toPJString(fittingWidth: proposedWidth, font: font)
+
+ attributedText = realignedText
}
-
- var isArabic: Bool {
- let predicate = NSPredicate(format: "SELF MATCHES %@", "(?s).*\\p{Arabic}.*")
- return predicate.evaluate(with: self)
+}
+#elseif canImport(AppKit)
+extension Label {
+ /// Method that will layouts text in a `Farsi` calligraphy friendly way.
+ /// - Warning: This is a computed heavy operation.
+ public func toPJString(idealWidth: CGFloat? = nil) throws {
+ let text = string
+
+ guard let font else {
+ throw PersianJustifyFailure.getFont
+ }
+
+ let proposedWidth = idealWidth ?? frame.width
+
+ let realignedText = text.toPJString(fittingWidth: proposedWidth, font: font)
+
+ textStorage?.append(realignedText)
}
-
- func getWords(separator: Character) -> [String] {
- return split(separator: separator).compactMap({$0.description})
+}
+#endif
+
+extension String {
+ private func splitStringToLines() -> [Line] {
+ replaceDoubleEmptyLines()
+ .splitWithLineSeparator()
+ .map { Line($0) }
}
-
- func getWordWidth(font: Font, isRequiredSpace: Bool = true) -> CGFloat {
- let text = isRequiredSpace ? (self + spaceCharacter.description) : self
- let attributedString = NSAttributedString(string: text, attributes: [.font: font])
- let line = CTLineCreateWithAttributedString(attributedString)
- var ascent: CGFloat = 0
- var descent: CGFloat = 0
- var leading: CGFloat = 0
- let width = CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
- return CGFloat(width)
+
+ private func replaceDoubleEmptyLines() -> String {
+ let doubleNextLine = LineBreakCharacter() + LineBreakCharacter()
+
+ // Replacing double empty lines with one empty line
+ return replacingOccurrences(
+ of: doubleNextLine,
+ with: LineBreakCharacter()
+ )
}
-
- func isSupportExtender() -> Bool {
- guard count > 1 else { return false }
- let array = Array(self)
- for i in stride(from: count-1, to: 0, by: -1) where (i > 0) && i < count {
- let char = array[i].description
- let rightChar = array[i-1].description
- if !forbiddenExtendableCharacters.contains(rightChar) && rightChar.isArabic && char.isArabic {
- return true
+
+ private func justify(
+ _ lines: [Line],
+ in proposedWidth: CGFloat,
+ with font: Font
+ ) -> NSAttributedString {
+ let final = NSMutableAttributedString(string: "")
+
+ lines.enumerated().forEach { index, line in
+ let words = line.getWords()
+
+ var currentLineWords: [Word] = []
+
+ words.forEach { word in
+ let canAddNewWord: Bool = {
+ let lineHasRoomForNextWord = currentLineWords.hasRoomForNextWord(
+ nextWord: word,
+ proposedWidth: proposedWidth,
+ font: font
+ )
+
+ lazy var isLineEmpty = currentLineWords.isEmpty
+
+ return lineHasRoomForNextWord || isLineEmpty
+ }()
+
+ if canAddNewWord {
+ currentLineWords.append(word)
+ }
+ // Line is filled and is ready to justify
+ else {
+ let justifiedLine = justifyLine(
+ from: currentLineWords,
+ in: proposedWidth,
+ with: font,
+ isLastLineInParagraph: false
+ )
+
+ // Appending space at the end
+ justifiedLine.appendSpaceCharacter()
+
+ final.append(justifiedLine)
+
+ currentLineWords = [word]
+ }
+ }
+
+ if !currentLineWords.isEmpty {
+ let extracted = justifyLine(
+ from: currentLineWords,
+ in: proposedWidth,
+ with: font,
+ isLastLineInParagraph: true
+ )
+
+ final.append(extracted)
+ }
+
+ // To avoid add extra next line at the end of text
+ if index < lines.count-1 {
+ final.append(LineBreakCharacter.attributedStringRepresentation)
}
}
- return false
- }
-}
-private extension [String] {
-
- func hasRoomForNextWord(nextWord: String, parentWidth: CGFloat, font: Font) -> Bool {
- let requiredWidth = nextWord.getWordWidth(font: font)
- let currentWidth = compactMap({$0.getWordWidth(font: font)}).reduce(0, +)
- return (currentWidth + requiredWidth) <= parentWidth
+ return final
}
-
- func joinWithSpace() -> String {
- return joined(separator: spaceCharacter.description)
+
+ private func justifyLine(
+ from words: [Word],
+ in proposedWidth: CGFloat,
+ with font: Font,
+ isLastLineInParagraph: Bool
+ ) -> NSMutableAttributedString {
+ words
+ .createLineFromWords()
+ .justify(
+ in: proposedWidth,
+ isLastLineInParagraph: isLastLineInParagraph,
+ font: font
+ )
}
}
+public enum PersianJustifyError: LocalizedError {
+ /// Failure to get text from the given view.
+ case getTextFailure(View)
+
+ /// Failure to get font from the given view.
+ case getFontFailure(View)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .getTextFailure(view):
+ return "Failure to get text from the \(view.debugDescription)"
+
+ case let .getFontFailure(view):
+ return "Failure to get font from \(view.debugDescription)"
+ }
+ }
+}
diff --git a/Sources/PersianJustify/Extensions/Typealias.swift b/Sources/Typealias.swift
similarity index 61%
rename from Sources/PersianJustify/Extensions/Typealias.swift
rename to Sources/Typealias.swift
index 6bb0d35..249cd21 100644
--- a/Sources/PersianJustify/Extensions/Typealias.swift
+++ b/Sources/Typealias.swift
@@ -1,9 +1,17 @@
+import Foundation
+
+/// File will contain only headers.
+
#if canImport(UIKit)
import UIKit
+
public typealias Font = UIFont
public typealias View = UIView
+public typealias Label = UILabel
#elseif canImport(AppKit)
import AppKit
+
public typealias Font = NSFont
public typealias View = NSView
+public typealias Label = NSTextView
#endif
diff --git a/Tests/PersianJustifyTests/GetFontTests.swift b/Tests/PersianJustifyTests/GetFontTests.swift
deleted file mode 100644
index 088a5d3..0000000
--- a/Tests/PersianJustifyTests/GetFontTests.swift
+++ /dev/null
@@ -1,69 +0,0 @@
-import XCTest
-@testable import PersianJustify
-
-#if canImport(UIKit)
-final class GetFontTests: XCTestCase {
-
- func testUILabelGetFont() {
- let sut = UILabel()
- let expFont = UIFont.systemFont(ofSize: 100)
- sut.font = expFont
-
- XCTAssertEqual(sut.font, expFont)
- XCTAssertEqual(sut.getFont(), expFont)
- XCTAssertEqual(sut.getFont(), sut.font)
- }
-
- func testUITextFieldGetFont() {
- let sut = UITextField()
- let expFont = UIFont.systemFont(ofSize: 100)
- sut.font = expFont
-
- XCTAssertEqual(sut.font, expFont)
- XCTAssertEqual(sut.getFont(), expFont)
- XCTAssertEqual(sut.getFont(), sut.font)
- }
-
- func testUITextViewGetFont() {
- let sut = UITextView()
- let expFont = UIFont.systemFont(ofSize: 100)
- sut.font = expFont
-
- XCTAssertEqual(sut.font, expFont)
- XCTAssertEqual(sut.getFont(), expFont)
- XCTAssertEqual(sut.getFont(), sut.font)
- }
-
- func testUIViewGetFont() {
- let sut = UIView()
- XCTAssertNil(sut.getFont())
- }
-}
-#endif
-
-#if canImport(AppKit)
-final class GetFontTests: XCTestCase {
-
- // There is no such a thing as `NSLabel` in the `AppKit`
-
- func testNSTextFieldGetFont() {
- let sut = NSTextField()
- let expFont = NSFont.systemFont(ofSize: 100)
- sut.font = expFont
-
- XCTAssertEqual(sut.font, expFont)
- XCTAssertEqual(sut.getFont(), expFont)
- XCTAssertEqual(sut.getFont(), sut.font)
- }
-
- func testUITextViewGetFont() {
- let sut = NSTextView()
- let expFont = NSFont.systemFont(ofSize: 100)
- sut.font = expFont
-
- XCTAssertEqual(sut.font, expFont)
- XCTAssertEqual(sut.getFont(), expFont)
- XCTAssertEqual(sut.getFont(), sut.font)
- }
-}
-#endif
diff --git a/Tests/PersianJustifyTests/MainFunctionalityTests.swift b/Tests/PersianJustifyTests/MainFunctionalityTests.swift
index 48b5246..f5e55f2 100644
--- a/Tests/PersianJustifyTests/MainFunctionalityTests.swift
+++ b/Tests/PersianJustifyTests/MainFunctionalityTests.swift
@@ -10,7 +10,6 @@ import XCTest
#if canImport(UIKit)
final class MainFunctionalityTests: XCTestCase {
-
func testMainFunctionIsGettingOutput() {
let text1 = ""
let text2 = "blah blah"
@@ -62,7 +61,6 @@ final class MainFunctionalityTests: XCTestCase {
#endif
private extension String {
-
func getNextLineCount()-> Int {
return filter({$0 == "\n"}).count
}
diff --git a/Tests/PersianJustifyTests/MesurementTests.swift b/Tests/PersianJustifyTests/MesurementTests.swift
new file mode 100644
index 0000000..3073044
--- /dev/null
+++ b/Tests/PersianJustifyTests/MesurementTests.swift
@@ -0,0 +1,43 @@
+import XCTest
+
+#if canImport(UIKit)
+final class MeasurementTests: XCTestCase {
+ func testPerformance() throws {
+ self.measure(metrics: [XCTCPUMetric(), XCTMemoryMetric()]) {
+ let texts = [
+ "",
+ "blah blah",
+ "السلام اللعیکم\nو رحمت الله و برکاتو"
+ ]
+
+ let sut = UILabel()
+ let fittingWidth = sut.frame.width
+
+ texts.forEach { text in
+ sut.attributedText = text.toPJString(fittingWidth: fittingWidth, font: sut.font)
+ }
+ }
+ }
+}
+#endif
+
+#if canImport(AppKit)
+final class MeasurementTests: XCTestCase {
+ func testPerformance() throws {
+ self.measure(metrics: [XCTCPUMetric(), XCTMemoryMetric()]) {
+ let texts = [
+ "",
+ "blah blah",
+ "السلام اللعیکم\nو رحمت الله و برکاتو"
+ ]
+
+ let sut = NSTextView()
+ let fittingWidth = sut.frame.width
+
+ texts.forEach { text in
+ sut.textStorage?.append(text.toPJString(fittingWidth: fittingWidth, font: sut.font!))
+ }
+ }
+ }
+}
+#endif