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