danielsaidi / RichTextKit

RichTextKit is a Swift SDK that helps you use rich text in Swift and SwiftUI.
MIT License
814 stars 97 forks source link

Trying to toggleStyle with emoticons selected breaks toggleStyle #149

Open noox89 opened 4 months ago

noox89 commented 4 months ago

https://github.com/danielsaidi/RichTextKit/assets/39130272/92d5d6b5-8b36-4a22-95ce-526c9787dd5c

When selecting text which includes emoticons and applying context.toggleStyle(.bold) or context.toggleStyle(.italic) the spacing gets disrupted and toggleStyle stops working. To get back to normal, font needs to be changed.

DominikBucher12 commented 4 months ago

I will take a look into this but if feels strange. Can you print attributes of the selected string, in order to distinguish, what is the issue (if its kerning or paragraph style or something else...) Thanks!

noox89 commented 4 months ago

Here is the print of the attributed string. The unexpected behaviour only happens with emoticon at the beginning of the line

NSColor = "<UIDynamicCatalogSystemColor: 0x132410540; name = labelColor>";
    NSFont = "<UICTFont: 0x131db6080> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 16.00pt";
    NSParagraphStyle = "Alignment Natural, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode WordWrapping, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection LeftToRight, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}😊{
    NSColor = "<UIDynamicCatalogSystemColor: 0x132410540; name = labelColor>";
    NSFont = "<UICTFont: 0x13b639800> font-family: \".AppleColorEmojiUI\"; font-weight: normal; font-style: normal; font-size: 16.00pt";
    NSOriginalFont = "<UICTFont: 0x131db6080> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 16.00pt";
    NSParagraphStyle = "Alignment Natural, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode WordWrapping, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection LeftToRight, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}this is a test{
    NSColor = "<UIDynamicCatalogSystemColor: 0x132410540; name = labelColor>";
    NSFont = "<UICTFont: 0x131db6080> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 16.00pt";
    NSParagraphStyle = "Alignment Natural, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode WordWrapping, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection LeftToRight, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}
DominikBucher12 commented 4 months ago

Hello @noox89 again,

I think I found the issue but I am not quite sure what to do with it right now.

You see, when apple renders Emojis, it uses font Apple Color Emoji which is special font with very nice symbolic traits which are probably not available in UIKit and we need to fix this in CoreText.

The issue is not just one function, altought I would be happy to fix it only in that way...

Given the font:

Snímek obrazovky 2024-02-29 v 23 54 31

The issue is in here:

func setRichTextStyle(
        _ style: RichTextStyle,
        to newValue: Bool
    ) {
        let value = newValue ? 1 : 0
        switch style {
        case .bold, .italic:
            let styles = richTextStyles
            guard styles.shouldAddOrRemove(style, newValue) else { return }
            >>>>>>>>>>>>  guard let font = richTextFont else { return } <<<<<<<<<<<<<<<
            guard let newFont = font.toggling(style) else { return }
            setRichTextFont(newFont)
        case .underlined:
            setRichTextAttribute(.underlineStyle, to: value)
        case .strikethrough:
            setRichTextAttribute(.strikethroughStyle, to: value)
        }
    }

which when we apply font style, we get richTextFont, our poor NSAttributes cannot get all the fonts at all ranges, so they return just the first font, which (unlucky for you) is Apple Color Emoji It sets this font to whole text together with bold and thats where the troubles begin. Because this font should be used for emojis only, it has some quirks like super long spaces etc and it causes the behaviour you are seeing.

I See the solution in some steps:

  1. Explicitly never allow font to be Apple Color Emoji in typing attributes or at any other text somehow
  2. When setting the bold style, set somehow just the style to the font and do not replace it, I think CoreText API is in need.
  3. ???
  4. PROFIT!

cc @danielsaidi looks like we have serious issue 😨

https://github.com/danielsaidi/RichTextKit/assets/17381941/338cb2cd-8d47-4b01-bbf9-edd80feb8049

BTW Thanks for reporting this❗️

DominikBucher12 commented 4 months ago

Looks like my little PoC fixes the issue, but I need to add unit tests probably for this afterwards...

https://github.com/danielsaidi/RichTextKit/assets/17381941/64f6ff5f-bd65-489f-8300-3447937a30e7

 func setRichTextStyle(
        _ style: RichTextStyle,
        to newValue: Bool
    ) {
        let value = newValue ? 1 : 0
        switch style {
        case .bold, .italic:
            carymaryfuk😅()
//            let styles = richTextStyles
//            guard styles.shouldAddOrRemove(style, newValue) else { return }
//            guard let font = richTextFont else { return }
//            guard let newFont = font.toggling(style) else { return }
//            setRichTextFont(newFont)
        case .underlined:
            setRichTextAttribute(.underlineStyle, to: value)
        case .strikethrough:
            setRichTextAttribute(.strikethroughStyle, to: value)
        }
    }

    func carymaryfuk😅() {
        var updatedAttributes: [NSAttributedString.Key: Any] = [:]

        richText.enumerateAttributes(in: selectedRange, options: [.longestEffectiveRangeNotRequired]) { (attributes, range, _) in
            var updatedFont: UIFont?
            if let existingFont = attributes[.font] as? UIFont {
                print(existingFont)
                let newFontDescriptor = existingFont.fontDescriptor
                var traits = existingFont.fontDescriptor.symbolicTraits

                // Check and modify bold trait
                if existingFont.isBold {
                    traits.remove(.traitBold)
                } else {
                    traits.insert(.traitBold)
                }

                // Check and modify italic trait
                if existingFont.isItalic {
                    traits.remove(.traitItalic)
                } else {
                    traits.insert(.traitItalic)
                }

                if let newFontDescriptor = newFontDescriptor.withSymbolicTraits(traits) {
                    updatedFont = UIFont(descriptor: newFontDescriptor, size: existingFont.pointSize)
                }

                updatedAttributes[.font] = updatedFont

                richText.enumerateAttributes(in: range, options: []) { (attributes, range, _) in
                    if let font = attributes[.font] as? UIFont {
                        updatedAttributes[.font] = updatedFont ?? font
                    }
                }

                let updatedAttributedString = NSAttributedString(string: richText.attributedSubstring(from: range).string, attributes: updatedAttributes)
                (self as? RichTextView)?.textStorage.replaceCharacters(in: range, with: updatedAttributedString)
            }
        }
    }

  ..... 
// Little helpers

extension UIFont {
    var isBold: Bool {
        return fontDescriptor.symbolicTraits.contains(.traitBold)
    }

    var isItalic: Bool {
        return fontDescriptor.symbolicTraits.contains(.traitItalic)
    }
}

You can see I am enumerating the attributes and setting each coresponding font to just bold or italic (with some sample extensions)

I am going to sleep now, but I guess I can take a look tommorow :)

Thanks and good night :)

noox89 commented 4 months ago

Thanks for explanation and quick turnaround!:) Btw I like your new func -> čáry máry fuk 🧙‍♂️

Díky/Thanks🥳

danielsaidi commented 4 months ago

Omg, great find @noox89 and @DominikBucher12

danielsaidi commented 3 months ago

Let's release 1.0 and look at this later.