locked
Cursor positioning in ligatures with DirectWrite RRS feed

  • Question

  • Hi,

    I'm trying to write a text editor using the DirectWrite APIs. I've run into a small problem while dealing with cursor positioning in Fonts that have ligatures. When displaying text using the Calibri font which has many ligatures for text like 'fi', 'ff' etc, I noticed that I couldn't position the cursor in between the ligatures. I'm using the HitTestTextPosition for finding the co-ordinates from a text position and the HitTestPoint function for the reverse. When dealing with a ligature such as for 'fi', the functions only ever return a position before and after the ligature, but never in between f & i, even when explicitly clicking between the two letters. I know that there are scripts like Devanagari with glyphs composed of multiple characters and it doesn't make sense to position a cursor in between characters. But this limitation doesn't make much sense for a language like English, where a user does expect to be able to move the cursor freely in between any set of characters. The Calibri font even includes the LigCaretList table (https://docs.microsoft.com/en-us/typography/opentype/spec/gdef) which has all the information required to position the cursor within ligatures. Is there any function / class within DirectWrite which exposes this information?

    While searching on stackoverflow I found another developer complaining about the same problem https://stackoverflow.com/questions/48462119/partial-ligature-selection-with-directwrite. That questions remains unanswered. 

    I'm led to believe that the uniscribe API can deal with this case, but I would really like to use DirectWrite and Direct2D.

    Regards,
    Unnerby

    Sunday, August 25, 2019 3:48 PM

All replies

  • Hi,

    I did a test with this sample on Windows 10 and I couldn't reproduce your problem. Users are able to move the cursor freely in between any set of characters.

    https://github.com/pauldotknopf/WindowsSDK7-Samples/tree/master/multimedia/DirectWrite/PadWrite

    Best regards,

    Drake


    MSDN Community Support
    Please remember to click "Mark as Answer" the responses that resolved your issue, and to click "Unmark as Answer" if not. This can be beneficial to other community members reading this thread. If you have any compliments or complaints to MSDN Support, feel free to contact MSDNFSF@microsoft.com.

    Tuesday, August 27, 2019 1:59 AM
  • Drake, thanks for looking into this.

    The PadWrite sample works because it uses the Arial font by default. Arial has no ligatures. Try writing a word like 'find' and using the format menu change the font to Calibri. Now, you won't be able to put the cursor between the f and i. I'm also running the PadWrite sample on windows 10. The stackoverflow post I linked above also mentions the problem with PadWrite and ligature fonts

    Regards,
    Unnerby


    • Edited by Unnerby Sunday, September 1, 2019 7:50 PM
    Tuesday, August 27, 2019 11:46 PM
  • Hi,

    I have reproduce this issue in PadWrite sample. The problem occurs after the method HitTestTextPosition, and the offset is always the end of the ligature. Since this may have something to do with the code inside this method, and no documentation mentions it. I will contact with concerned engineers to confirm this behavior.

    You can also submit this issue in the Feedback Hub(If so, please share the link here for the update).

    Best regards,

    Drake


    MSDN Community Support
    Please remember to click "Mark as Answer" the responses that resolved your issue, and to click "Unmark as Answer" if not. This can be beneficial to other community members reading this thread. If you have any compliments or complaints to MSDN Support, feel free to contact MSDNFSF@microsoft.com.

    Wednesday, August 28, 2019 9:21 AM
  • Thanks Drake. I will wait to hear from you.

    Best regards,
    Unnerby


    • Edited by Unnerby Sunday, September 1, 2019 7:50 PM
    Wednesday, August 28, 2019 7:54 PM
  • *Bump*

    I've tried poring through the DirectWrite documentation but have had no luck. I would appreciate it if you could get back with an answer soon.

    Thanks,
    Unnerby

    Tuesday, September 10, 2019 8:28 PM
  • In order to be able to put the caret between “f” in “i”, maybe you can disable the ligatures. For example, go to OnChooseFont in PadWrite sample and add this fragment:

     

    IDWriteTypography* typoFeature = NULL;
    dwriteFactory_->CreateTypography( &typoFeature );
     
    UINT32 value = 0;
     
    typoFeature->AddFontFeature( { DWRITE_FONT_FEATURE_TAG_CONTEXTUAL_LIGATURES, value } );
    typoFeature->AddFontFeature( { DWRITE_FONT_FEATURE_TAG_DISCRETIONARY_LIGATURES, value } );
    typoFeature->AddFontFeature( { DWRITE_FONT_FEATURE_TAG_HISTORICAL_LIGATURES, value } );
    typoFeature->AddFontFeature( { DWRITE_FONT_FEATURE_TAG_STANDARD_LIGATURES, value } );
    typoFeature->AddFontFeature( { DWRITE_FONT_FEATURE_TAG_REQUIRED_LIGATURES, value } );
     
    textLayout->SetTypography( typoFeature, textRange );
    SafeRelease( &typoFeature );
     


    • Edited by Viorel_MVP Wednesday, September 11, 2019 7:50 AM
    Wednesday, September 11, 2019 7:49 AM
  • Hi Unnerby,

    Besides disable the ligatures, we can use DirectWrite’s HitTest APIs to determine the caret position. Those APIs return a text length larger than 1 if multiple code units (each code unit is 16 bit) form a cluster that should be treated as one.  A good example for this is a Chinese character that is encoded with a surrogate pair.  As the APIs returns 2, the sample does not split those two code units.  So, when you press right arrow key when the caret is at the left edge of the character, the caret moves to the right edge.  It would be bad to move the caret to the middle of the character.  The problem is that they also return 2 for “fi” ligature.  For this, we want to move the caret to the middle of the ligature.

    To address this issue:

    1. Added code to detect such a ligature and split code units so that the caret can be placed at each code unit (See IsWesternLigature). 
    2. Modified the text selection logic accordingly (See TextEditor::DrawPage).  
    3. Modified the caret location logic to divide the pixel width of the cluster by the number of code units (GetCaretRect). 
    4. Generalized the code so that if the caret is within a cluster (which never occurred before these changes), the sample works properly.  You will find the related code when you search for “If it is inside the cluster”.

    Function IsWesternLigature

    bool TextEditor::IsWesternLigature(UINT32 length, UINT32 textPosition)
    {
        bool isLigature = false;
    
        if (length > 1)
        {
            TextAnalysis* sink = new(std::nothrow) TextAnalysis(&text_, textPosition);
            HRESULT hr = sink ? S_OK : E_OUTOFMEMORY;
            if (SUCCEEDED(hr))
            {
                hr = textAnalyzer_->AnalyzeScript(sink, textPosition, length, sink);
            }
    
            DWRITE_SCRIPT_ANALYSIS scriptAnalysis = {};
            if (SUCCEEDED(hr))
            {
                UINT32 textLength;
                hr = sink->GetScriptAnalysisResult(&scriptAnalysis, &textLength);
                if (SUCCEEDED(hr) && !textLength)
                {
                    hr = E_UNEXPECTED;
                }
            }
    
            if (SUCCEEDED(hr))
            {
                // If western script
                if (scriptAnalysis.script == scriptAnalysis_.script)
                {
                    // If a ligature
                    EditableLayout::CaretFormat caretFormat;
    
                    caretFormat.fontFamilyName[0] = '\0';
                    hr = textLayout_->GetFontFamilyName(textPosition, &caretFormat.fontFamilyName[0], ARRAYSIZE(caretFormat_.fontFamilyName));
                    if (SUCCEEDED(hr))
                    {
                        caretFormat_.localeName[0] = '\0';
                        // Get the locale
                        hr = textLayout_->GetLocaleName(textPosition, &caretFormat.localeName[0], ARRAYSIZE(caretFormat_.localeName));
                    }
    
                    if (SUCCEEDED(hr))
                    {
                        hr = textLayout_->GetFontWeight(textPosition, &caretFormat.fontWeight);
                    }
    
                    if (SUCCEEDED(hr))
                    {
                        hr = textLayout_->GetFontStyle(textPosition, &caretFormat.fontStyle);
                    }
    
                    if (SUCCEEDED(hr))
                    {
                        hr = textLayout_->GetFontStretch(textPosition, &caretFormat.fontStretch);
                    }
                    
                    if (SUCCEEDED(hr))
                    {
                        hr = textLayout_->GetFontSize(textPosition, &caretFormat.fontSize);
                    }
                    
                    if (SUCCEEDED(hr))
                    {
                        hr = textLayout_->GetUnderline(textPosition, &caretFormat.hasUnderline);
                    }
                    
                    if (SUCCEEDED(hr))
                    {
                        hr = textLayout_->GetStrikethrough(textPosition, &caretFormat.hasStrikethrough);
                    }
                    
                    IDWriteFontCollection* fontCollection = nullptr;
                    if (SUCCEEDED(hr))
                    {
                        hr = layoutEditor_.GetFactory()->GetSystemFontCollection(&fontCollection);
                    }
    
                    UINT32 familyIndex = 0;
                    if (SUCCEEDED(hr))
                    {
                        BOOL familyExists;
                        hr = fontCollection->FindFamilyName(caretFormat.fontFamilyName, &familyIndex, &familyExists);
                        if (SUCCEEDED(hr) && !familyExists)
                        {
                            hr = DWRITE_E_NOFONT;
                        }
                    }
    
                    IDWriteFontFamily* fontFamily = nullptr;
                    if (SUCCEEDED(hr))
                    {
                        hr = fontCollection->GetFontFamily(familyIndex, &fontFamily);
                    }
    
                    IDWriteFont* font = nullptr;
                    if (SUCCEEDED(hr))
                    {
                        hr = fontFamily->GetFont(0, &font);
                    }
    
                    IDWriteFontFace* fontFace = nullptr;;
                    if (SUCCEEDED(hr))
                    {
                        hr = font->CreateFontFace(&fontFace);
                    }
    
                    if (SUCCEEDED(hr))
                    {
                        DWRITE_FONT_FEATURE fontFeatures[] =
                        {
                            {DWRITE_FONT_FEATURE_TAG_CONTEXTUAL_LIGATURES, 0},
                            {DWRITE_FONT_FEATURE_TAG_DISCRETIONARY_LIGATURES, 0},
                            {DWRITE_FONT_FEATURE_TAG_HISTORICAL_LIGATURES, 0},
                            {DWRITE_FONT_FEATURE_TAG_STANDARD_LIGATURES, 0},
                            {DWRITE_FONT_FEATURE_TAG_REQUIRED_LIGATURES, 0},
                        };
    
                        DWRITE_TYPOGRAPHIC_FEATURES typographicFeatures;
                        typographicFeatures.featureCount = ARRAYSIZE(fontFeatures);
                        typographicFeatures.features = fontFeatures;
    
                        const DWRITE_TYPOGRAPHIC_FEATURES* featureParameter = &typographicFeatures;
                        UINT32 featureRangeLengths = length;
    
                        const int maxGlyphCount = 100;
                        UINT16 clusterMap[maxGlyphCount];
                        DWRITE_SHAPING_TEXT_PROPERTIES textProps[maxGlyphCount];
                        UINT16 glyphIndices[maxGlyphCount];
                        DWRITE_SHAPING_GLYPH_PROPERTIES glyphProps[maxGlyphCount];
                        UINT32 actualGlyphCount;
    
                        hr = textAnalyzer_->GetGlyphs(&text_[textPosition], length, fontFace, FALSE, FALSE, &scriptAnalysis, nullptr, nullptr,
                            &featureParameter, &featureRangeLengths, 1, maxGlyphCount,
                            clusterMap, textProps, glyphIndices, glyphProps, &actualGlyphCount);
    
                        if (SUCCEEDED(hr) && (actualGlyphCount != 1))
                        {
                            isLigature = true;
    #ifdef _DEBUG
                            OutputDebugString(L"Western script ligature: ");
                            OutputDebugString(text_.substr(textPosition, 10).c_str());
                            OutputDebugString(L"\r\n");
    #endif
                        }
                    }
    
                    SafeRelease(&fontFace);
                    SafeRelease(&font);
                    SafeRelease(&fontFamily);
                    SafeRelease(&fontCollection);
                }
            }
    
            if (sink)
            {
                delete sink;
            }
        }
    
        return isLigature;
    }

    Function GetCaretRect:

    void TextEditor::GetCaretRect(OUT RectF& rect)
    {
        // Gets the current caret position (in untransformed space).
    
        RectF zeroRect = {};
        rect = zeroRect;
    
        if (textLayout_ == NULL)
            return;
    
        // Translate text character offset to point x,y.
        DWRITE_HIT_TEST_METRICS caretMetrics;
        float caretX, caretY;
        float height;
    
        textLayout_->HitTestTextPosition(
            caretPosition_,
            caretPositionOffset_ > 0, // trailing if nonzero, else leading edge
            &caretX,
            &caretY,
            &caretMetrics
            );
    
        height = caretMetrics.height;
    
        // If a selection exists, draw the caret using the
        // line size rather than the font size.
        DWRITE_TEXT_RANGE selectionRange = GetSelectionRange();
        if (selectionRange.length > 0)
        {
            DWRITE_HIT_TEST_METRICS caretSelectionMetrics;
            UINT32 actualHitTestCount = 1;
            textLayout_->HitTestTextRange(
                caretPosition_,
                0, // length
                0, // x
                0, // y
                &caretSelectionMetrics,
                1,
                &actualHitTestCount
                );
    
            caretY = caretMetrics.top;
            height = caretMetrics.height;
        }
    
        // The default thickness of 1 pixel is almost _too_ thin on modern large monitors,
        // but we'll use it.
        DWORD caretIntThickness = 2;
        SystemParametersInfo(SPI_GETCARETWIDTH, 0, &caretIntThickness, FALSE);
        const float caretThickness = float(caretIntThickness);
    
        
        // Return the caret rect, untransformed.
        float adjustment = 0;
        UINT32 absolutePosition = caretPosition_ + caretPositionOffset_;
    
        // If it is inside the cluster (the cluster edges excluded)
        if (absolutePosition > caretMetrics.textPosition)
        {
            if (caretMetrics.length > 0)
            {
                if (caretPositionOffset_ > 0)
                {
                    adjustment = -float(caretMetrics.length - (absolutePosition - caretMetrics.textPosition)) / caretMetrics.length;
                }
                else
                {
                    adjustment = float(absolutePosition - caretMetrics.textPosition) / caretMetrics.length;
                }
            }
        }
    
        rect.left   = (caretX - caretThickness / 2.0f) + (caretMetrics.width * adjustment);
             
        rect.right = rect.left + caretThickness;
        rect.top    = caretY;
        rect.bottom = caretY + height;
    }
    

    Function TextEditor::DrawPage

    void TextEditor::DrawPage(RenderTarget& target)
    {
        // Draws the background, page, selection, and text.
    
        // Calculate actual location in render target based on the
        // current page transform and location of edit control.
        D2D1::Matrix3x2F pageTransform;
        GetViewMatrix(&Cast(pageTransform));
    
        // Scale/Rotate canvas as needed
        DWRITE_MATRIX previousTransform;
        target.GetTransform(previousTransform);
        target.SetTransform(Cast(pageTransform));
    
        // Draw the page
        D2D1_POINT_2F pageSize = GetPageSize(textLayout_);
        RectF pageRect = {0, 0, pageSize.x, pageSize.y};
    
        target.FillRectangle(pageRect, *pageBackgroundEffect_);
    
        // Determine actual number of hit-test ranges
        DWRITE_TEXT_RANGE caretRange = GetSelectionRange();
        
        // left edge
        if (caretRange.length > 0)
        {
            DWRITE_HIT_TEST_METRICS ligatureHitTestMetrics;
            float caretX, caretY;
            UINT32 hitTestCount = 0;
    
            // Get the size of the following cluster.
            textLayout_->HitTestTextPosition(
                caretRange.startPosition,
                false, // does not matter which one as we don't use rect info from there.
                &caretX,
                &caretY,
                &ligatureHitTestMetrics
            );
    
            // If it is inside the cluster (the cluster edges excluded)
            if (caretRange.startPosition > ligatureHitTestMetrics.textPosition)
            {
                // get a cluster range
                textLayout_->HitTestTextRange(
                    ligatureHitTestMetrics.textPosition,
                    ligatureHitTestMetrics.length,
                    0, // x
                    0, // y
                    NULL,
                    0, // metrics count
                    &hitTestCount
                );
    
                // Allocate enough room to return all hit-test metrics.
                std::vector<DWRITE_HIT_TEST_METRICS> hitTestMetrics(hitTestCount);
    
                textLayout_->HitTestTextRange(
                    ligatureHitTestMetrics.textPosition,
                    ligatureHitTestMetrics.length,
                    0, // x
                    0, // y
                    &hitTestMetrics[0],
                    static_cast<UINT32>(hitTestMetrics.size()),
                    &hitTestCount
                );
    
                // Draw the selection ranges behind the text.
                if (hitTestCount > 0)
                {
                    if (ligatureHitTestMetrics.length > 0)
                    {
                        const DWRITE_HIT_TEST_METRICS& htm = hitTestMetrics[0];
    
                        UINT32 leftOffsetLength = caretRange.startPosition - ligatureHitTestMetrics.textPosition;
                        UINT32 rightOffsetLength = 0;
    
                        // if the right edge is also inside (exclusive) of the cluster
                        if (ligatureHitTestMetrics.textPosition + ligatureHitTestMetrics.length > caretRange.startPosition + caretRange.length)
                        {
                            rightOffsetLength = ligatureHitTestMetrics.length - (caretRange.startPosition + caretRange.length - ligatureHitTestMetrics.textPosition);
                        }
    
    
                        float averageWidth = htm.width / ligatureHitTestMetrics.length;
    
                        RectF highlightRect = {
                            (htm.left + leftOffsetLength * averageWidth),
                            htm.top,
                            // we don't just add to the left to avoid rounding if the the right edge is at the end of the cluster
                            (htm.left + htm.width -rightOffsetLength * averageWidth),
                            (htm.top + htm.height)
                        };
    
                        target.SetAntialiasing(false);
                        target.FillRectangle(highlightRect, *textSelectionEffect_);
                        target.SetAntialiasing(true);
    
                        // if the right edge is either at the end of the cluster or beyond
                        if (!rightOffsetLength)
                        {
                            // calculate the remaining length
                            caretRange.length -= ligatureHitTestMetrics.textPosition + ligatureHitTestMetrics.length - caretRange.startPosition;
                        }
                        else
                        {
                            // nothing left to do
                            caretRange.length = 0;
                        }
    
                        caretRange.startPosition = ligatureHitTestMetrics.textPosition + ligatureHitTestMetrics.length;
                    }
                }
            }
        }
    
        // right edge
        if (caretRange.length > 0)
        {
            DWRITE_HIT_TEST_METRICS ligatureHitTestMetrics;
            float caretX, caretY;
            UINT32 hitTestCount = 0;
    
            // Get the size of the following cluster.
            textLayout_->HitTestTextPosition(
                caretRange.startPosition + caretRange.length,
                false, // does not matter which one as we don't use rect info from there.
                &caretX,
                &caretY,
                &ligatureHitTestMetrics
            );
    
            // If it is inside the cluster (the cluster edges excluded)
            if (caretRange.startPosition + caretRange.length > ligatureHitTestMetrics.textPosition)
            {
                textLayout_->HitTestTextRange(
                    ligatureHitTestMetrics.textPosition,
                    ligatureHitTestMetrics.length,
                    0, // x
                    0, // y
                    NULL,
                    0, // metrics count
                    &hitTestCount
                );
    
                // Allocate enough room to return all hit-test metrics.
                std::vector<DWRITE_HIT_TEST_METRICS> hitTestMetrics(hitTestCount);
    
                textLayout_->HitTestTextRange(
                    ligatureHitTestMetrics.textPosition,
                    ligatureHitTestMetrics.length,
                    0, // x
                    0, // y
                    &hitTestMetrics[0],
                    static_cast<UINT32>(hitTestMetrics.size()),
                    &hitTestCount
                );
    
                // Draw the selection ranges behind the text.
                if (hitTestCount > 0)
                {
                    float adjustment = 0;
                    if (ligatureHitTestMetrics.length > 0)
                    {
                        adjustment = float(caretRange.startPosition + caretRange.length - ligatureHitTestMetrics.textPosition) / ligatureHitTestMetrics.length;
    
                        const DWRITE_HIT_TEST_METRICS& htm = hitTestMetrics[0];
                        RectF highlightRect = {
                            htm.left,
                            htm.top,
                            (htm.left + htm.width * adjustment),
                            (htm.top + htm.height)
                        };
    
                        target.SetAntialiasing(false);
                        target.FillRectangle(highlightRect, *textSelectionEffect_);
                        target.SetAntialiasing(true);
    
                        caretRange.length -= caretRange.startPosition + caretRange.length - ligatureHitTestMetrics.textPosition;
                    }
                }
            }
        }
    
        UINT32 actualHitTestCount = 0;
    
        if (caretRange.length > 0)
        {
            textLayout_->HitTestTextRange(
                caretRange.startPosition,
                caretRange.length,
                0, // x
                0, // y
                NULL,
                0, // metrics count
                &actualHitTestCount
            );
        }
    
        // Allocate enough room to return all hit-test metrics.
        std::vector<DWRITE_HIT_TEST_METRICS> hitTestMetrics(actualHitTestCount);
    
        if (caretRange.length > 0)
        {
            textLayout_->HitTestTextRange(
                caretRange.startPosition,
                caretRange.length,
                0, // x
                0, // y
                &hitTestMetrics[0],
                static_cast<UINT32>(hitTestMetrics.size()),
                &actualHitTestCount
            );
        }
    
        // Draw the selection ranges behind the text.
        if (actualHitTestCount > 0)
        {
            // Note that an ideal layout will return fractional values,
            // so you may see slivers between the selection ranges due
            // to the per-primitive antialiasing of the edges unless
            // it is disabled (better for performance anyway).
            target.SetAntialiasing(false);
    
            for (size_t i = 0; i < actualHitTestCount; ++i)
            {
                const DWRITE_HIT_TEST_METRICS& htm = hitTestMetrics[i];
                RectF highlightRect = {
                    htm.left,
                    htm.top,
                    (htm.left + htm.width),
                    (htm.top + htm.height)
                };
    
                target.FillRectangle(highlightRect, *textSelectionEffect_);
            }
    
            target.SetAntialiasing(true);
        }
        
        // Draw our caret onto the render target.
        RectF caretRect;
        GetCaretRect(caretRect);
        target.SetAntialiasing(false);
        target.FillRectangle(caretRect, *caretBackgroundEffect_);
        target.SetAntialiasing(true);
    
        // Draw text
        target.DrawTextLayout(textLayout_, pageRect);
    
        // Draw the selection ranges in front of images.
        // This shades otherwise opaque images so they are visibly selected,
        // checking the isText field of the hit-test metrics.
        if (actualHitTestCount > 0)
        {
            // Note that an ideal layout will return fractional values,
            // so you may see slivers between the selection ranges due
            // to the per-primitive antialiasing of the edges unless
            // it is disabled (better for performance anyway).
            target.SetAntialiasing(false);
    
            for (size_t i = 0; i < actualHitTestCount; ++i)
            {
                const DWRITE_HIT_TEST_METRICS& htm = hitTestMetrics[i];
                if (htm.isText)
                    continue; // Only draw selection if not text.
    
                RectF highlightRect = {
                    htm.left,
                    htm.top,
                    (htm.left + htm.width),
                    (htm.top  + htm.height)
                };
                
                target.FillRectangle(highlightRect, *imageSelectionEffect_);
            }
    
            target.SetAntialiasing(true);
        }
    
        // Restore transform
        target.SetTransform(previousTransform);
    }

    Hope this is helpful.

    Regards & Fei




    MSDN Community Support
    Please remember to click "Mark as Answer" the responses that resolved your issue, and to click "Unmark as Answer" if not. This can be beneficial to other community members reading this thread. If you have any compliments or complaints to MSDN Support, feel free to contact MSDNFSF@microsoft.com.

    Monday, May 18, 2020 3:02 AM