microsoft / WindowsAppSDK

The Windows App SDK empowers all Windows desktop apps with modern Windows UI, APIs, and platform features, including back-compat support, shipped via NuGet.
https://docs.microsoft.com/windows/apps/windows-app-sdk/
MIT License
3.75k stars 309 forks source link

DWriteCore Typographics features do not work #4050

Open timeester3648 opened 7 months ago

timeester3648 commented 7 months ago

Describe the bug

DWriteCore's IDWriteTypography does not properly modify the typography when setting it in a text IDWriteTextLayout4. When I tell it to enable some typographic features in a range, where the range does not start at 0 (DWRITE_TEXT_RANGE::startPosition), it will not work. If I give it a start position of 0, it will enable the feature (if it works in the first place) beyond DWRITE_TEXT_RANGE::length. Feature disabling also does not work, if a feature is automatically enabled (which is the case for ligatures for Google Material Symbols) and I disable it, even for the entire text (start at 0, use UINT32_MAX for end) it still keeps using ligatures.

The fraction feature has even more odd behaviour. Aside from not working properly it enables and disables differently depending on which size I set the font to.

I tried this with WindowsAppSdk 1.4.231115000 and 1.5.231202003-experimental1 (for my own application), and also the WindowsAppSdk samples, I do not know if that uses a different version.

MSVC 2022 17.8.3

Steps to reproduce the bug

  1. Clone the Windows App SDK samples.
  2. Open the DWriteCoreGallery sample solution.
  3. Open Introduction.md and edit the first line from "# DWriteCore Sample Gallery" to "# DWriteCore Sample Gallery 1/3" and above the "# API Layers" add "1/3 1/3" so you get this:
# DWriteCore Sample Gallery 1/3

This sample application demonstrates the DWriteCore API, which is a reimplementation of the Windows DirectWrite API.
Select items from the *Scenario* menu to see pages that demonstrate various API functionality.

DWriteCore is a low-level API for formatting and rendering text. It is a nano-COM API, meaning it uses COM-style 
interfaces (derived from `IUnknown`) but does not actually use the COM run-time. It is therefore not necessary to
call `CoCreateInstance` before using DWriteCore. Instead, call the `DWriteCreateFactory` function to create a factory
object (`IDWriteFactory7`), and then call factory methods to create other objects or perform other actions.

1/3 1/3

# API Layers

The **text layout API** is the highest layer of the DWriteCore API. It includes *text format* objects (`IDWriteTextFormat4`),
which encapsulate formatting properties, and *text layout* objects (`IDWriteTextLayout4`), which represents formatted text
strings. A text layout object exposes methods for drawing, getting metrics, hit testing, and so on. Each paragraph on this
page is a text layout object.

The **font API** exposes information about fonts, and provides functionality needed for text layout and rendering. It includes
*font collection* objects (`IDWriteFontCollection3`), which are collections of fonts grouped into families, *font set* objects
(`IDWriteFontSet3`), which are flat collections of fonts, and *font face* objects (`IDWriteFontFace6`), which represent specific
fonts.

The **text rendering API** provides interfaces uses for rendering text. When you call a text layout object's `Draw` method,
it calls back to an interface (`IDWriteTextRenderer1`), which must be implemented by the application or by some other library.
The use of an abstract callback interface enables DWriteCore to be decoupled from any particular graphics engine. The text
renderer implementation can use text rendering APIs provided by DWriteCore to help with rendering glyphs. The `TextRenderer` 
class in this application provides a sample implementation of the `IDWriteTextRenderer1` interface.

The **text analysis API** provides low-level APIs for sophisticated applications that implement their own text layout engines.
This includes script analysis, bidi analysis, shaping, and so on.
  1. Open MarkdownWindow.cpp and edit g_defaultStyle to use Arial:
const MarkdownStyle g_defaultStyle =
{
    L"Arial",       // headingFamilyName 
    L"Arial",       // bodyFamilyName
    L"Consolas",    // codeFamilyName

    g_defaultHeadingAxisValues,
    g_defaultBodyAxisValues,
    g_defaultBoldAxisValues,
    g_defaultItalicAxisValues,

    28.0f,  // headingFontSize
    14.0f,  // bodyFontSize
    13.0f   // codeFontSize
};
  1. in the while (inputPos < inputEnd) loop at line 306, under the textLayout creation add this:
wil::com_ptr<IDWriteTypography> typo;
THROW_IF_FAILED(g_factory->CreateTypography(&typo));
typo->AddFontFeature(DWRITE_FONT_FEATURE{
    .nameTag = DWRITE_FONT_FEATURE_TAG_FRACTIONS,
    .parameter = 1u
});

THROW_IF_FAILED(textLayout->SetTypography(typo.get(), DWRITE_TEXT_RANGE{ 0u, UINT32_MAX }));

Observe how it works properly (first screenshot).

  1. Now move the above code in the for (DWRITE_TEXT_RANGE textRange : boldRanges) loop and change the DWRITE_TEXT_RANGE{ 0u, UINT32_MAX } to textRange. You now see that the fraction feature does not work anymore. Even though "1/3" is still bold which means the font axis does work. This shows that textRange holds the correct range. But the typographic feature does not work.

The loop now looks like this:

for (DWRITE_TEXT_RANGE textRange : boldRanges)
{
    wil::com_ptr<IDWriteTypography> typo;
    THROW_IF_FAILED(g_factory->CreateTypography(&typo));
    typo->AddFontFeature(DWRITE_FONT_FEATURE{
        .nameTag = DWRITE_FONT_FEATURE_TAG_FRACTIONS,
        .parameter = 1u
    });

    THROW_IF_FAILED(textLayout->SetTypography(typo.get(), textRange));
    THROW_IF_FAILED(textLayout->SetFontAxisValues(style.boldAxisValues.data(), static_cast<uint32_t>(style.boldAxisValues.size()), textRange));
}

Which gives this result (second screenshot)

  1. You can also use DWRITE_TEXT_RANGE{ 0u, UINT32_MAX }, which also does not work, even though it starts at 0, which makes it work sometimes, but not in this case (third screenshot)

Since the markdown window uses separate text layouts I cannot show you the other bugs using it easily, so now I'll take screenshots from my application (using 1.5.231202003-experimental1, but has the same behaviour as 1.4.231115000). In the fourth screenshot you can see when I use DWriteCore and set the entire text range to use the fraction feature, and in the fifth screenshot you can see what happens when I make the "1111111111" a different size (does not matter how small the difference in size is). I did not change anything aside from the size (72pt to 72.1pt, converted to use DIP), but now always the first 3 characters are in "numerator" mode if it is a number (observe screenshot 6). This also happens when I limit the fractions feature to the first three characters only ("1/3"). However, if I change the size back again, it works like normal, but the range still leaks through to the rest of the text: observe screenshot 7 (only the first 3 characters have the feature applied), also see screenshot 8 for how it completely breaks when I also change the size of the text after "1/3".

Expected behavior

Typography features work properly. They work when I enable them, they do not work when I disable, unlike what now happens. And when I enable them for a specific range, they work only within that range and do not bleed to the rest of the text.

Screenshots

Schermafbeelding 2023-12-21 164559

afbeelding

afbeelding

afbeelding

afbeelding

afbeelding

afbeelding

afbeelding

NuGet package version

Windows App SDK 1.4.3: 1.4.231115000

Packaging type

Packaged (MSIX), Unpackaged

Windows version

Windows 11 version 22H2 (22621, 2022 Update)

IDE

Visual Studio 2022

Additional context

No response

riverar commented 6 months ago

On the first pass, after executing ParsePseudoMarkdownBlock(inputPos, inputEnd, text, boldRanges, italicRanges, codeRanges), none of the range vectors are populated. So moving the feature code into the for (DWRITE_TEXT_RANGE textRange : boldRanges) loop effectively turns the operation into a no-op. (As expected, nothing happens.)

Surrounding the added fractions in the Markdown document with bold (**) markup gets you into that loop and things start working again. But beware that ParsePseudoMarkdownBlock does not support parsing bold markup in headings (blockType == MarkdownBlockType::Body).

Are you seeing something else?

timeester3648 commented 6 months ago

I apologize if that example was a bit flawed. The bug becomes more apparent later in the report (when I use my application). Setting a topology does work sometimes, but when mixing font sizes, it does not. I do not know whether other features also make this bug appear, but I at the very least know that changing font size (using SetFontSize) breaks the typographic features.

I changed the Introduction.md to this:

# DWriteCore Sample Gallery 1/3

This sample application demonstrates the DWriteCore API, which is a reimplementation of the Windows DirectWrite API.
Select items from the *Scenario* menu to see pages that demonstrate various API functionality.

DWriteCore is a low-level API for formatting and rendering text. It is a nano-COM API, meaning it uses COM-style 
interfaces (derived from `IUnknown`) but does not actually use the COM run-time. It is therefore not necessary to
call `CoCreateInstance` before using DWriteCore. Instead, call the `DWriteCreateFactory` function to create a factory
object (`IDWriteFactory7`), and then call factory methods to create other objects or perform other actions.

1/3 1/3 111111/33333

# API Layers

The **text layout API** is the highest layer of the DWriteCore API. It includes *text format* objects (`IDWriteTextFormat4`),
which encapsulate formatting properties, and *text layout* objects (`IDWriteTextLayout4`), which represents formatted text
strings. A text layout object exposes methods for drawing, getting metrics, hit testing, and so on. Each paragraph on this
page is a text layout object.

The **font API** exposes information about fonts, and provides functionality needed for text layout and rendering. It includes
*font collection* objects (`IDWriteFontCollection3`), which are collections of fonts grouped into families, *font set* objects
(`IDWriteFontSet3`), which are flat collections of fonts, and *font face* objects (`IDWriteFontFace6`), which represent specific
fonts.

The **text rendering API** provides interfaces uses for rendering text. When you call a text layout object's `Draw` method,
it calls back to an interface (`IDWriteTextRenderer1`), which must be implemented by the application or by some other library.
The use of an abstract callback interface enables DWriteCore to be decoupled from any particular graphics engine. The text
renderer implementation can use text rendering APIs provided by DWriteCore to help with rendering glyphs. The `TextRenderer` 
class in this application provides a sample implementation of the `IDWriteTextRenderer1` interface.

The **text analysis API** provides low-level APIs for sophisticated applications that implement their own text layout engines.
This includes script analysis, bidi analysis, shaping, and so on.

And changed the loop to this:

while (inputPos < inputEnd)
{
    auto blockType = ParsePseudoMarkdownBlock(inputPos, inputEnd, text, boldRanges, italicRanges, codeRanges);
    auto textLayout = CreateTextLayout(GetTextFormat(blockType), text);

    wil::com_ptr<IDWriteTypography> typo;
    THROW_IF_FAILED(g_factory->CreateTypography(&typo));
    typo->AddFontFeature(DWRITE_FONT_FEATURE{
        .nameTag = DWRITE_FONT_FEATURE_TAG_FRACTIONS,
        .parameter = 1u
     });

    THROW_IF_FAILED(textLayout->SetTypography(typo.get(), DWRITE_TEXT_RANGE{ 4u, 3u }));

    for (DWRITE_TEXT_RANGE textRange : boldRanges)
    {
        THROW_IF_FAILED(textLayout->SetFontAxisValues(style.boldAxisValues.data(), static_cast<uint32_t>(style.boldAxisValues.size()), textRange));
    }

    for (DWRITE_TEXT_RANGE textRange : italicRanges)
    {
        THROW_IF_FAILED(textLayout->SetFontAxisValues(style.italicAxisValues.data(), static_cast<uint32_t>(style.italicAxisValues.size()), textRange));
    }

    for (DWRITE_TEXT_RANGE textRange : codeRanges)
    {
        THROW_IF_FAILED(textLayout->SetFontFamilyName(style.codeFamilyName, textRange));
        THROW_IF_FAILED(textLayout->SetFontSize(style.codeFontSize, textRange));
    }

    result.push_back(std::move(textLayout));
}

With that, I get a good result: afbeelding

However, if I change the size of the font after the range I set the typographic feature (which should not break anything), it fails like this: afbeelding With always the first X amount of characters (if they are [0,9]) in that odd upper-fraction mode: afbeelding (even though an 'a' is in between, it still does that odd upper-fraction mode)

I changed the size by adding THROW_IF_FAILED(textLayout->SetFontSize(32.0f, DWRITE_TEXT_RANGE{ 7u, UINT32_MAX - 7u })); directly under the line THROW_IF_FAILED(textLayout->SetTypography(typo.get(), DWRITE_TEXT_RANGE{ 4u, 3u }));. If I move that line just above the line result.push_back(std::move(textLayout)); to prevent the size overriding due to styling, the same bug appears: afbeelding

Even just changing the size of 1 character makes the bug appear (changed the size code to: THROW_IF_FAILED(textLayout->SetFontSize(32.0f, DWRITE_TEXT_RANGE{ 7u, 1u }));): afbeelding

I want to make clear that some features do work, for example, small caps (my application, not the samples): afbeelding Observe the feature not starting at 0, and after the feature, the size of the font is different, but the feature still works.

riverar commented 6 months ago

Here's a potentially simpler set of repro steps:

  1. Clone the Windows App SDK samples
  2. Open the DWriteCoreGallery sample solution (Samples\TextRendering\cpp-win32\DWriteCoreGallery.sln)
  3. Open MarkdownWindow.cpp
  4. Replace the CreateTextLayoutsFromPseudoMarkdown function with the following:
std::vector<wil::com_ptr<IDWriteTextLayout4>> CreateTextLayoutsFromPseudoMarkdown(std::span<char const> inputText, MarkdownStyle style)
{
    std::vector<wil::com_ptr<IDWriteTextLayout4>> result;

    auto bodyFormat = CreateTextFormat(style.bodyFamilyName, style.bodyFontSize, style.bodyAxisValues);
    auto textLayout = CreateTextLayout(
        bodyFormat.get(),
        //0         10        20        30        40        50        60
        //|         |         |         |         |         |         |
        L"This should be formatted as a fraction => 1/2. This should not => 1/2."
    );

    wil::com_ptr<IDWriteTypography> typo;
    THROW_IF_FAILED(g_factory->CreateTypography(&typo));

    // Enable fractions feature for span (0-45)
    typo->AddFontFeature(DWRITE_FONT_FEATURE{
        .nameTag = DWRITE_FONT_FEATURE_TAG_FRACTIONS,
        .parameter = 1u
        });
    THROW_IF_FAILED(textLayout->SetTypography(typo.get(), DWRITE_TEXT_RANGE{ 0u, 45u }));

    // Increase font size for span (46-*)
    THROW_IF_FAILED(textLayout->SetFontSize(32.0f, DWRITE_TEXT_RANGE{ 46u, UINT_MAX - 46u }));

    result.push_back(std::move(textLayout));

    return result;
}
  1. Compile/execute the sample
  2. Observe fractions feature is unexpectedly applying to the second fraction in the sample text. (Screenshot below.)

image

riverar commented 6 months ago

Tagging @niklasb-ms

codendone commented 5 months ago

internal bug