14
12

More than 3 years have passed since last update.

[UE4] RichTextBlock でルビを振る

Last updated at Posted at 2021-01-05

RichTextBlock で遊んでみよう第二弾!
95d8cf2ddb6d148d67b1a3787dec970b.png
前回に引き続き RichTextBlock の機能を拡張して作っていきます。
RichTextBlock には Decorator クラスというものがあってぢゃな~!

RichTextBlock はデータ テーブル アセットを使用して追加したスタイルとカスタマイズ機能を管理します。データ テーブル アセットによって、自作のスタイルを作成したり、プロジェクトに必要な動作を定義する Decorator クラスを書くことができます。自作の Decorator クラスを書く際の取り掛かりとして使用していただけるように RichTextBlockImageDecorator というサンプル Decorator クラスを含めました。UMG の RichTextBlock についてもっと学んで自作の Decorator クラスを書けるようになるためには以下の手順に従ってください。

公式ドキュメントでは絵文字の実装が解説されてますが、調べてみるとテキストブロックの中に独自の Slate が実装できるみたいなんですね。なんでもできるっていいですね~。

URichTextBlockDecorator の実装

<ruby label="わがはい">吾輩</>は猫である。

Decorator の基本構文は <ElementName AttributeName="AttributeValue">Content</> となっています。FDefaultRichTextMarkupParser::ParseLineRanges を参照。
今回は タグ名 :ruby、ルビ文字 :label で実装します。
タグ名に一致した部分に Decorator の実装で Slate が生成されて置換されるって感じです。

URichTextBlockDecorator の拡張

URichTextBlockDecorator を継承した URichTextBlockRubyDecorator を作成します。
今回は実装クラスをまるまる載せるしかないので、長いですね。

URichTextBlockRubyDecorator 実装コード
RichTextBlockRubyDecorator.h
UCLASS( Blueprintable )
class URichTextBlockRubyDecorator : public URichTextBlockDecorator
{
    GENERATED_BODY()

    friend class FRichTextRuby;

protected:
    virtual TSharedPtr<ITextDecorator> CreateDecorator( URichTextBlock* InOwner ) override;

protected:
    UPROPERTY( EditAnywhere, BlueprintReadWrite, AdvancedDisplay )
    FString ParseTagName = TEXT( "ruby" );

    UPROPERTY( EditAnywhere, BlueprintReadWrite, AdvancedDisplay )
    FString AttributeLabelName = TEXT( "label" );

    UPROPERTY( EditAnywhere, BlueprintReadWrite, AdvancedDisplay )
    FString AttributeStyleName = TEXT( "style" );

    UPROPERTY( EditAnywhere, BlueprintReadWrite )
    FName RubyTextStyleNameFormat = TEXT( "{0}.Ruby" );

    UPROPERTY( EditAnywhere, BlueprintReadWrite )
    FVector2D RubyOffset = FVector2D::ZeroVector;
};
RichTextBlockRubyDecorator.cpp
class SRichTextRuby : public SCompoundWidget
{
public:
    SLATE_BEGIN_ARGS( SRichTextRuby )
        : _DisplayText()
        , _DisplayTextStyle( &FCoreStyle::Get().GetWidgetStyle<FTextBlockStyle>( "NormalText" ) )
        , _RubyText()
        , _RubyTextStyle( &FCoreStyle::Get().GetWidgetStyle<FTextBlockStyle>( "NormalText" ) )
    {
    }
    SLATE_ATTRIBUTE( FText, DisplayText )
    SLATE_STYLE_ARGUMENT( FTextBlockStyle, DisplayTextStyle )
    SLATE_ATTRIBUTE( FText, RubyText )
    SLATE_STYLE_ARGUMENT( FTextBlockStyle, RubyTextStyle )
    SLATE_END_ARGS()

public:
    void Construct( const FArguments& InArgs, const FVector2D& RubyOffset )
    {
        DisplayText = InArgs._DisplayText;
        const FTextBlockStyle* DisplayTextStyle = InArgs._DisplayTextStyle;

        if ( !DisplayText.Get().IsEmptyOrWhitespace() )
        {
            RubyText = InArgs._RubyText;
            const FTextBlockStyle* RubyTextStyle = InArgs._RubyTextStyle;

            const TSharedRef<FSlateFontMeasure> FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
            FVector2D DisplayTextSize = FontMeasure->Measure( DisplayText.Get(), DisplayTextStyle->Font, 1.0f );
            FVector2D RubyTextSize = FontMeasure->Measure( RubyText.Get(), RubyTextStyle->Font, 1.0f );

            TSharedPtr<SOverlay> Overlay =         //
                SNew( SOverlay ) +                 //
                SOverlay::Slot()[                  //
                    SNew( SConstraintCanvas ) +    //
                    SConstraintCanvas::Slot()
                        .Offset( FMargin( 0.f, 0.f,    //
                            DisplayTextSize.X, FontMeasure->GetMaxCharacterHeight( DisplayTextStyle->Font, 1.0f ) ) )
                        .Anchors( FAnchors( 0.5f, 0.5f, 0.5f, 0.5f ) )
                        .Alignment( FVector2D( 0.5f, 0.5f ) )[    //
                            SNew( STextBlock )                    //
                                .TextStyle( DisplayTextStyle )
                                .Text( this, &SRichTextRuby::GetDisplayText )]];

            auto IsEmptyOrContainsWhitespace = [&]( const FText& TextData ) {
                const FString& DisplayString = TextData.ToString();
                return DisplayString.IsEmpty() || DisplayString.Contains( TEXT( "\u0020" ) );
            };
            if ( !IsEmptyOrContainsWhitespace( DisplayText.Get() ) )
            {
                Overlay->AddSlot()[                //
                    SNew( SConstraintCanvas ) +    //
                    SConstraintCanvas::Slot()
                        .Offset( FMargin( RubyOffset.X, RubyOffset.Y,    //
                            RubyTextSize.X, FontMeasure->GetMaxCharacterHeight( RubyTextStyle->Font, 1.0f ) ) )
                        .Anchors( FAnchors( 0.5f, 0.f, 0.5f, 0.f ) )
                        .Alignment( FVector2D( 0.5f, 1.f ) )[    //
                            SNew( STextBlock )                   //
                                .TextStyle( RubyTextStyle )
                                .Text( this, &SRichTextRuby::GetRubyText )]];
            }

            ChildSlot[    //
                SNew( SBox )
                    .WidthOverride( FMath::Max( DisplayTextSize.X, RubyTextSize.X ) )
                    .HeightOverride( DisplayTextSize.Y - 1.f )[    // FIXME: -1 を入れることでなぜか改行時の行間サイズが合う。なぜ。
                        Overlay.ToSharedRef()]];
        }
    }

    FText GetDisplayText() const { return DisplayText.Get(); }
    FText GetRubyText() const { return RubyText.Get(); }

private:
    TAttribute<FText> DisplayText;
    TAttribute<FText> RubyText;
};

class FRichTextRuby : public ITextDecorator
{
public:
    FRichTextRuby( URichTextBlock* InOwner, URichTextBlockRubyDecorator* InDecorator )
        : Owner( InOwner ), Decorator( InDecorator )
    {
    }

protected:
    virtual bool Supports( const FTextRunParseResults& RunParseResult, const FString& Text ) const override
    {
        if ( RunParseResult.Name == Decorator->ParseTagName &&     //
             RunParseResult.MetaData.Contains( Decorator->AttributeLabelName ) )
        {
            const FTextRange& WordRange = RunParseResult.MetaData[Decorator->AttributeLabelName];
            const FString TagWord = Text.Mid( WordRange.BeginIndex, WordRange.EndIndex - WordRange.BeginIndex );

            return !FText::FromString( TagWord ).IsEmptyOrWhitespace();
        }
        return false;
    }

    // CreateDecoratorWidgetにISlateStyle*Styleを渡すためにコピペ
    virtual TSharedRef<ISlateRun> Create( const TSharedRef<class FTextLayout>& TextLayout,
        const FTextRunParseResults& RunParseResult, const FString& DisplayText, const TSharedRef<FString>& InOutModelText,
        const ISlateStyle* Style ) override final
    {
        FTextRange ModelRange;
        ModelRange.BeginIndex = InOutModelText->Len();

        FTextRunInfo RunInfo(
            RunParseResult.Name, FText::FromString( DisplayText.Mid( RunParseResult.ContentRange.BeginIndex,
                                     RunParseResult.ContentRange.EndIndex - RunParseResult.ContentRange.BeginIndex ) ) );
        for ( const TPair<FString, FTextRange>& Pair : RunParseResult.MetaData )
        {
            RunInfo.MetaData.Add( Pair.Key, DisplayText.Mid( Pair.Value.BeginIndex, Pair.Value.EndIndex - Pair.Value.BeginIndex ) );
        }

        const FTextBlockStyle& TextStyle = Owner->GetCurrentDefaultTextStyle();

        TSharedPtr<ISlateRun> SlateRun;
        TSharedPtr<SWidget> DecoratorWidget = CreateDecoratorWidget( RunInfo, TextStyle, Style );
        if ( DecoratorWidget.IsValid() )
        {
            *InOutModelText += TEXT( '\u200B' );    // Zero-Width Breaking Space
            ModelRange.EndIndex = InOutModelText->Len();

            // Calculate the baseline of the text within the owning rich text
            const TSharedRef<FSlateFontMeasure> FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
            int16 WidgetBaseline = FontMeasure->GetBaseline( TextStyle.Font ) - FMath::Min( 0.0f, TextStyle.ShadowOffset.Y );

            FSlateWidgetRun::FWidgetRunInfo WidgetRunInfo( DecoratorWidget.ToSharedRef(), WidgetBaseline );
            SlateRun = FSlateWidgetRun::Create( TextLayout, RunInfo, InOutModelText, WidgetRunInfo, ModelRange );
        }

        return SlateRun.ToSharedRef();
    }

protected:
    TSharedPtr<SWidget> CreateDecoratorWidget(
        const FTextRunInfo& RunInfo, const FTextBlockStyle& TextStyle, const ISlateStyle* Style ) const
    {
        FName TextStyleName = TEXT( "Default" );
        FName RubyTextStyleName = *FString::Format( *Decorator->RubyTextStyleNameFormat.ToString(), { TextStyleName.ToString() } );

        FTextBlockStyle DisplayTextStyle = TextStyle;
        FTextBlockStyle RubyTextStyle = Style->HasWidgetStyle<FTextBlockStyle>( RubyTextStyleName )
                                            ? Style->GetWidgetStyle<FTextBlockStyle>( RubyTextStyleName )
                                            : TextStyle;

        if ( RunInfo.MetaData.Contains( Decorator->AttributeStyleName ) )
        {
            TextStyleName = *RunInfo.MetaData[Decorator->AttributeStyleName];
            if ( Style->HasWidgetStyle<FTextBlockStyle>( TextStyleName ) )
            {
                DisplayTextStyle = Style->GetWidgetStyle<FTextBlockStyle>( TextStyleName );
            }

            RubyTextStyleName = *FString::Format( *Decorator->RubyTextStyleNameFormat.ToString(), { TextStyleName.ToString() } );
            if ( Style->HasWidgetStyle<FTextBlockStyle>( RubyTextStyleName ) )
            {
                RubyTextStyle = Style->GetWidgetStyle<FTextBlockStyle>( RubyTextStyleName );
            }
        }

        return SNew( SRichTextRuby, Decorator->RubyOffset )
            .DisplayText( RunInfo.Content )
            .DisplayTextStyle( &DisplayTextStyle )
            .RubyText( FText::FromString( RunInfo.MetaData[Decorator->AttributeLabelName] ) )
            .RubyTextStyle( &RubyTextStyle );
    }

private:
    TWeakObjectPtr<URichTextBlock> Owner;
    TWeakObjectPtr<URichTextBlockRubyDecorator> Decorator;
};

TSharedPtr<ITextDecorator> URichTextBlockRubyDecorator::CreateDecorator( URichTextBlock* InOwner )
{
    return MakeShareable( new FRichTextRuby( InOwner, this ) );
}

Slate 黒魔術

SNew( SOverlay ) +                 // clang-format 使ってると
SOverlay::Slot()[                  // 変なとこで改行しちゃうのでコメントで制御
    SNew( SConstraintCanvas ) +    //
    SConstraintCanvas::Slot()
        .Offset( FMargin( 0.f, 0.f,    //
            DisplayTextSize.X, FontMeasure->GetMaxCharacterHeight( DisplayTextStyle->Font, 1.0f ) ) )
        .Anchors( FAnchors( 0.5f, 0.5f, 0.5f, 0.5f ) )
        .Alignment( FVector2D( 0.5f, 0.5f ) )[    //
            SNew( STextBlock )                    //
                .TextStyle( DisplayTextStyle )
                .Text( this, &SRichTextRuby::GetDisplayText )]];

最初見たときは、すごい!なんじゃこら!ってなりましたが、一周回ると逆に分かりやすい、不思議なコードです。
UMG のツリー構造と同じで SNew して Slot()[] を繰り返すだけ・・・!
あとインデントが大事、Python みたいですね。最初は UMG 開きながらコーディングするのをおススメします。
a4cb35f3fed41e6501669df190258bb0.png
今回実装した Slate はこんな感じ。なんで CanvasPanel 入れ子になっているのかは後述。

タイプライターと組み合わせるとピクピク動く

490f27d684ae7207cdf527cf7caaa2f1.gif
今回本当に苦労したところはココなんですが、Slate の領域計算で微妙に誤差がある(深くは追えていない)らしく、表示がガタついてしまいます。
自動計算系の処理(STextBlock::Justification や SOverlay::HAlignment などのセンタリング)は使わず、SBox や SConstraintCanvas で値を指定して固めましょう。

TextStyle を指定する

fbc385d0c9e26ffa7f38a743c71ab502.png

ここには重要な<ruby label="きょうくん" style="Important">教訓</>があります。

色変えと併用したい場合もありますよね。Attribute で指定しましょう。

ここには重要な<Important><ruby label="きょうくん">教訓</></>があります。

もしかすると、本当はこう書きたいかもしれません。
しかし実装(正規表現の構文解析)の都合上、入れ子構造は難しいのです。

参考:正規表現に見切りをつけるとき

TextBlock 縦方向のセンタリング

UTextLayoutWidget::LineHeightPercentage という非常に便利なパラメータがあるのですが、30f5c91d81d7cf0f415c6500ad942df4.png
これの縦方向の Alignment が Top 固定なのです。
9408ecb96f0cf304113494b790b3624b.png
本当はこうしたい。
256d7f4a5cc02c525f7e720e504eb9f4.png
エンジンを改造する以外には方法が思いつかなかったので、修正部分を乗せときます。。誰かタスケテ!

TextLayout.cpp
// UE4.26 L409 あたり
        float CurrentHorizontalPos = 0.0f;
        for (int32 Index = 0; Index < OutSoftLine.Num(); Index++)
        {
            const TSharedRef< ILayoutBlock > Block = OutSoftLine[ Index ];
            const TSharedRef< IRun > Run = Block->GetRun();

            const int16 BlockBaseline = Run->GetBaseLine(Scale);
            const int16 VerticalOffset = MaxAboveBaseline - Block->GetSize().Y - BlockBaseline;
            const int8 BlockKerning = Run->GetKerning(Block->GetTextRange().BeginIndex, Scale, RunTextContext);

            Block->SetLocationOffset(FVector2D(CurrentOffset.X + CurrentHorizontalPos + BlockKerning, CurrentOffset.Y + VerticalOffset));

//@third party code BEGIN koorinonaka
// LineHeightPercentageを変更したときのVAlignをTopではなくCenter基準にする
            FVector2D BlockLocationOffset = Block->GetLocationOffset();
            Block->SetLocationOffset( FVector2D(
                BlockLocationOffset.X,
                BlockLocationOffset.Y - Block->GetSize().Y * 0.5f * ( 1.f - LineHeightPercentage ) ) );
//@third party code END koorinonaka

            CurrentHorizontalPos += Block->GetSize().X;
        }

吾輩は猫である。名前はまだ無い。

eed38816db911a27be1b08144bdb4bfb.gif
青空文庫から拝借しました。ルビも一緒にコピペできるなんて便利な世の中だ。

14
12
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
12