editeng/Library_editeng.mk                      |    1 
 editeng/inc/TextPortion.hxx                     |   11 +
 editeng/inc/editattr.hxx                        |   10 +
 editeng/source/editeng/ContentNode.cxx          |    7 -
 editeng/source/editeng/editattr.cxx             |    9 +
 editeng/source/editeng/editdbg.cxx              |    9 +
 editeng/source/editeng/editdoc.cxx              |    5 
 editeng/source/editeng/eerdll.cxx               |    2 
 editeng/source/editeng/impedit.hxx              |    1 
 editeng/source/editeng/impedit3.cxx             |  166 +++++++++++++++++++++++-
 editeng/source/items/rubyitem.cxx               |  134 +++++++++++++++++++
 editeng/source/items/textitem.cxx               |    1 
 include/editeng/editids.hrc                     |    2 
 include/editeng/eeitem.hxx                      |    4 
 include/editeng/memberids.h                     |    6 
 include/editeng/rubyitem.hxx                    |   59 ++++++++
 include/editeng/unoprnms.hxx                    |    4 
 include/editeng/unotext.hxx                     |    7 -
 include/svl/poolitem.hxx                        |    1 
 sw/inc/unomid.h                                 |    7 -
 sw/source/core/txtnode/fmtatr2.cxx              |    1 
 sw/source/core/unocore/unoobj.cxx               |    1 
 sw/source/core/unocore/unoport.cxx              |    1 
 vcl/qa/cppunit/pdfexport/data/textbox-ruby.fodt |  149 +++++++++++++++++++++
 vcl/qa/cppunit/pdfexport/pdfexport2.cxx         |   89 ++++++++++++
 25 files changed, 671 insertions(+), 16 deletions(-)

New commits:
commit f08a984a94f5b504c75f4f4ce8a98d0bc074e0ee
Author:     Jonathan Clark <jonat...@libreoffice.org>
AuthorDate: Thu May 1 10:02:55 2025 -0600
Commit:     Jonathan Clark <jonat...@libreoffice.org>
CommitDate: Thu May 15 13:06:12 2025 +0200

    editeng: Initial ruby character implementation
    
    Implements a minimal functional version of ruby character layout and
    rendering in Edit Engine. Many features are missing from this
    implementation, and will be added in future commits.
    
    Currently, ruby characters cannot be created via the user interface.
    Ruby character styles are also currently hard-coded.
    
    Change-Id: Ia7c18ccae6c37cd320acc214574ba3123c18ddb0
    Reviewed-on: https://gerrit.libreoffice.org/c/core/+/185210
    Reviewed-by: Jonathan Clark <jonat...@libreoffice.org>
    Tested-by: Jenkins

diff --git a/editeng/Library_editeng.mk b/editeng/Library_editeng.mk
index cfd4f357270c..05ced9a8614a 100644
--- a/editeng/Library_editeng.mk
+++ b/editeng/Library_editeng.mk
@@ -88,6 +88,7 @@ $(eval $(call gb_Library_add_exception_objects,editeng,\
     editeng/source/items/optitems \
     editeng/source/items/paperinf \
     editeng/source/items/paraitem \
+    editeng/source/items/rubyitem \
     editeng/source/items/svdfield \
     editeng/source/items/svxfont \
     editeng/source/items/textitem \
diff --git a/editeng/inc/TextPortion.hxx b/editeng/inc/TextPortion.hxx
index 3c1caa882df3..84c964340f55 100644
--- a/editeng/inc/TextPortion.hxx
+++ b/editeng/inc/TextPortion.hxx
@@ -86,10 +86,18 @@ struct ExtraPortionInfo
     }
 };
 
+struct RubyPortionInfo
+{
+    tools::Long nXOffset = 0;
+    tools::Long nYOffset = 0;
+    sal_uInt16 nMaxAscent = 0;
+};
+
 class TextPortion
 {
 private:
     std::unique_ptr<ExtraPortionInfo> xExtraInfos;
+    std::unique_ptr<RubyPortionInfo> xRubyInfos;
     sal_Int32 nLen;
     Size aOutSz = Size(-1, -1);
     PortionKind nKind = PortionKind::TEXT;
@@ -142,6 +150,9 @@ public:
 
     ExtraPortionInfo* GetExtraInfos() const { return xExtraInfos.get(); }
     void SetExtraInfos(ExtraPortionInfo* p) { xExtraInfos.reset(p); }
+
+    RubyPortionInfo const* GetRubyInfos() const { return xRubyInfos.get(); }
+    void SetRubyInfos(std::unique_ptr<RubyPortionInfo> p) { xRubyInfos = 
std::move(p); }
 };
 
 /* vim:set shiftwidth=4 softtabstop=4 expandtab: */
diff --git a/editeng/inc/editattr.hxx b/editeng/inc/editattr.hxx
index 68b5f019f88d..806f97625d6c 100644
--- a/editeng/inc/editattr.hxx
+++ b/editeng/inc/editattr.hxx
@@ -48,6 +48,7 @@ class EditCharAttrib
     sal_Int32               nEnd;
     bool                bFeature    :1;
     bool                bEdge       :1;
+    bool bExpandable : 1;
 
 public:
     EditCharAttrib(SfxItemPool&, const SfxPoolItem&, sal_Int32 nStart, 
sal_Int32 nEnd);
@@ -92,6 +93,9 @@ public:
 
     bool    IsEdge() const      { return bEdge; }
     void    SetEdge( bool b )   { bEdge = b; }
+
+    bool IsExpandable() const { return !bFeature && bExpandable; }
+    void SetExpandable(bool b) { bExpandable = b; }
 };
 
 inline sal_Int32 EditCharAttrib::GetLen() const
@@ -295,7 +299,13 @@ public:
     virtual void    SetFont( SvxFont& rFont, OutputDevice* pOutDev ) override;
 };
 
+class EditCharAttribRuby final : public EditCharAttrib
+{
+public:
+    EditCharAttribRuby(SfxItemPool&, const SfxPoolItem&, sal_Int32 nStart, 
sal_Int32 nEnd);
 
+    virtual void SetFont(SvxFont& rFont, OutputDevice* pOutDev) override;
+};
 
 class EditCharAttribTab final : public EditCharAttrib
 {
diff --git a/editeng/source/editeng/ContentNode.cxx 
b/editeng/source/editeng/ContentNode.cxx
index 8937b39002c7..27764b997161 100644
--- a/editeng/source/editeng/ContentNode.cxx
+++ b/editeng/source/editeng/ContentNode.cxx
@@ -70,7 +70,7 @@ void ContentNode::ExpandAttribs( sal_Int32 nIndex, sal_Int32 
nNew )
                 pAttrib->MoveForward( nNew );
             }
             // 0: Expand empty attribute, if at insertion point
-            else if ( pAttrib->IsEmpty() )
+            else if (pAttrib->IsEmpty() && pAttrib->IsExpandable())
             {
                 // Do not check Index, an empty one could only be there
                 // When later checking it anyhow:
@@ -90,7 +90,8 @@ void ContentNode::ExpandAttribs( sal_Int32 nIndex, sal_Int32 
nNew )
                 // and if not in exclude list!
                 // Otherwise, a UL will go on until a new ULDB, expanding both
 //              if ( !pAttrib->IsFeature() && !rExclList.FindAttrib( 
pAttrib->Which() ) )
-                if ( !pAttrib->IsFeature() && 
!maCharAttribList.FindEmptyAttrib( pAttrib->Which(), nIndex ) )
+                if (pAttrib->IsExpandable()
+                    && !maCharAttribList.FindEmptyAttrib(pAttrib->Which(), 
nIndex))
                 {
                     if ( !pAttrib->IsEdge() )
                         pAttrib->Expand( nNew );
@@ -107,7 +108,7 @@ void ContentNode::ExpandAttribs( sal_Int32 nIndex, 
sal_Int32 nNew )
             // 3: Attribute starts on index...
             else if ( pAttrib->GetStart() == nIndex )
             {
-                if ( pAttrib->IsFeature() )
+                if (!pAttrib->IsExpandable())
                 {
                     pAttrib->MoveForward( nNew );
                     bResort = true;
diff --git a/editeng/source/editeng/editattr.cxx 
b/editeng/source/editeng/editattr.cxx
index 75bbcabc5a66..deab7b6b7dea 100644
--- a/editeng/source/editeng/editattr.cxx
+++ b/editeng/source/editeng/editattr.cxx
@@ -52,6 +52,7 @@ EditCharAttrib::EditCharAttrib(SfxItemPool& rPool, const 
SfxPoolItem& rItem, sal
 , nEnd(nE)
 , bFeature(false)
 , bEdge(false)
+, bExpandable(true)
 {
     assert((rItem.Which() >= EE_ITEMS_START) && (rItem.Which() <= 
EE_ITEMS_END));
     assert((rItem.Which() < EE_FEATURE_START) || (rItem.Which() > 
EE_FEATURE_END) || (nE == (nS+1)));
@@ -244,7 +245,15 @@ void EditCharAttribLanguage::SetFont( SvxFont& rFont, 
OutputDevice* )
     rFont.SetLanguage( static_cast<const 
SvxLanguageItem*>(GetItem())->GetLanguage() );
 }
 
+EditCharAttribRuby::EditCharAttribRuby(SfxItemPool& rPool, const SfxPoolItem& 
rItem,
+                                       sal_Int32 nStartIn, sal_Int32 nEndIn)
+    : EditCharAttrib(rPool, rItem, nStartIn, nEndIn)
+{
+    assert(rItem.Which() == EE_CHAR_RUBY);
+    SetExpandable(false);
+}
 
+void EditCharAttribRuby::SetFont(SvxFont&, OutputDevice*) {}
 
 EditCharAttribShadow::EditCharAttribShadow(SfxItemPool& rPool, const 
SfxPoolItem& rItem, sal_Int32 _nStart, sal_Int32 _nEnd)
 : EditCharAttrib(rPool, rItem, _nStart, _nEnd)
diff --git a/editeng/source/editeng/editdbg.cxx 
b/editeng/source/editeng/editdbg.cxx
index d2a9290dae00..c9b2fd890e08 100644
--- a/editeng/source/editeng/editdbg.cxx
+++ b/editeng/source/editeng/editdbg.cxx
@@ -38,6 +38,7 @@
 #include <editeng/shdditem.hxx>
 #include <editeng/escapementitem.hxx>
 #include <editeng/kernitem.hxx>
+#include <editeng/rubyitem.hxx>
 #include <editeng/wrlmitem.hxx>
 #include <editeng/autokernitem.hxx>
 #include <editeng/langitem.hxx>
@@ -173,6 +174,11 @@ struct DebOutBuffer
     {
         appendHeightAndPts(descr, rItem.GetValue(), 
rPool.GetMetric(rItem.Which()));
     }
+    void append(std::string_view descr, const SvxRubyItem& rItem)
+    {
+        str.append(OString::Concat(descr)
+                   + OUStringToOString(rItem.GetText(), 
RTL_TEXTENCODING_UTF8));
+    }
 };
 }
 
@@ -304,6 +310,9 @@ static OString DbgOutItem(const SfxItemPool& rPool, const 
SfxPoolItem& rItem)
         case EE_CHAR_XMLATTRIBS:
             buffer.str.append("XMLAttribs=...");
         break;
+        case EE_CHAR_RUBY:
+            buffer.append("Ruby=", rItem.StaticWhichCast(EE_CHAR_RUBY));
+        break;
     }
     return buffer.str.makeStringAndClear();
 }
diff --git a/editeng/source/editeng/editdoc.cxx 
b/editeng/source/editeng/editdoc.cxx
index 590901f626fc..8c7277f74224 100644
--- a/editeng/source/editeng/editdoc.cxx
+++ b/editeng/source/editeng/editdoc.cxx
@@ -308,6 +308,11 @@ EditCharAttrib* MakeCharAttrib( SfxItemPool& rPool, const 
SfxPoolItem& rAttr, sa
             return new EditCharAttribBackgroundColor(rPool, rAttr, nS, nE );
         }
         break;
+        case EE_CHAR_RUBY:
+        {
+            return new EditCharAttribRuby(rPool, rAttr, nS, nE);
+        }
+        break;
         default:
         break;
     }
diff --git a/editeng/source/editeng/eerdll.cxx 
b/editeng/source/editeng/eerdll.cxx
index 9bc101646c7e..d7ed5397ebb3 100644
--- a/editeng/source/editeng/eerdll.cxx
+++ b/editeng/source/editeng/eerdll.cxx
@@ -50,6 +50,7 @@
 #include <editeng/kernitem.hxx>
 #include <editeng/lrspitem.hxx>
 #include <editeng/postitem.hxx>
+#include <editeng/rubyitem.hxx>
 #include <editeng/shdditem.hxx>
 #include <editeng/udlnitem.hxx>
 #include <editeng/ulspitem.hxx>
@@ -154,6 +155,7 @@ ItemInfoPackage& getItemInfoPackageEditEngine()
             { EE_CHAR_CASEMAP, new SvxCaseMapItem( SvxCaseMap::NotMapped, 
EE_CHAR_CASEMAP ), SID_ATTR_CHAR_CASEMAP, SFX_ITEMINFOFLAG_NONE  },
             { EE_CHAR_GRABBAG, new SfxGrabBagItem( EE_CHAR_GRABBAG ), 
SID_ATTR_CHAR_GRABBAG, SFX_ITEMINFOFLAG_NONE  },
             { EE_CHAR_BKGCOLOR, new SvxColorItem( COL_AUTO, EE_CHAR_BKGCOLOR 
), SID_ATTR_CHAR_BACK_COLOR, SFX_ITEMINFOFLAG_NONE  },
+            { EE_CHAR_RUBY, new SvxRubyItem( EE_CHAR_RUBY ), 
SID_ATTR_CHAR_RUBY, SFX_ITEMINFOFLAG_NONE },
             { EE_FEATURE_TAB, new SfxVoidItem( EE_FEATURE_TAB ), 0, 
SFX_ITEMINFOFLAG_NONE  },
             { EE_FEATURE_LINEBR, new SfxVoidItem( EE_FEATURE_LINEBR ), 0, 
SFX_ITEMINFOFLAG_NONE  },
             { EE_FEATURE_NOTCONV, new SvxColorItem( COL_RED, 
EE_FEATURE_NOTCONV ), SID_ATTR_CHAR_CHARSETCOLOR, SFX_ITEMINFOFLAG_NONE  },
diff --git a/editeng/source/editeng/impedit.hxx 
b/editeng/source/editeng/impedit.hxx
index 6466f563365b..36804880cd23 100644
--- a/editeng/source/editeng/impedit.hxx
+++ b/editeng/source/editeng/impedit.hxx
@@ -696,6 +696,7 @@ private:
     bool createLinesForEmptyParagraph(ParaPortion& rParaPortion, bool 
bIsScaling = false);
     tools::Long calculateMaxLineWidth(tools::Long nStartX, SvxLRSpaceItem 
const& rLRItem,
                                       const SvxFontUnitMetrics& rMetrics);
+    void populateRubyInfo(ParaPortion& rParaPortion, EditLine* pLine);
     bool CreateLines(sal_Int32 nPara, sal_uInt32 nStartPosY, bool bIsScaling = 
false);
 
     void                CreateAndInsertEmptyLine(ParaPortion& rParaPortion);
diff --git a/editeng/source/editeng/impedit3.cxx 
b/editeng/source/editeng/impedit3.cxx
index ba01e1c3584e..b11dc06fbfe3 100644
--- a/editeng/source/editeng/impedit3.cxx
+++ b/editeng/source/editeng/impedit3.cxx
@@ -41,6 +41,7 @@
 #include <editeng/fontitem.hxx>
 #include <editeng/wghtitem.hxx>
 #include <editeng/postitem.hxx>
+#include <editeng/rubyitem.hxx>
 #include <editeng/langitem.hxx>
 #include <editeng/frmdiritem.hxx>
 #include <editeng/scriptspaceitem.hxx>
@@ -627,6 +628,102 @@ tools::Long 
ImpEditEngine::calculateMaxLineWidth(tools::Long nStartX, SvxLRSpace
     return nMaxLineWidth;
 }
 
+void ImpEditEngine::populateRubyInfo(ParaPortion& rParaPortion, EditLine* 
pLine)
+{
+    ContentNode* const pNode = rParaPortion.GetNode();
+    SvxFont aTmpFont(pNode->GetCharAttribs().GetDefFont());
+    SvxFont aRubyStartFont = aTmpFont;
+
+    sal_Int32 nTextPos = pLine->GetStart();
+    const EditCharAttrib* pNextRubyAttr
+        = pNode->GetCharAttribs().FindNextAttrib(EE_CHAR_RUBY, nTextPos);
+    TextPortion* pTPRubyStart = nullptr;
+    tools::Long nTPMaxAscent = 0;
+    tools::Long nTPTotalWidth = 0;
+    for (sal_Int32 nP = pLine->GetStartPortion(); pNextRubyAttr && nP <= 
pLine->GetEndPortion();
+         ++nP)
+    {
+        SeekCursor(pNode, nTextPos, aTmpFont);
+
+        TextPortion& rTP = rParaPortion.GetTextPortions()[nP];
+        rTP.SetRubyInfos({});
+
+        if (nTextPos == pNextRubyAttr->GetStart())
+        {
+            pTPRubyStart = &rTP;
+            aRubyStartFont = aTmpFont;
+            SeekCursor(pNode, nTextPos, aTmpFont);
+
+            nTPMaxAscent = 0;
+            nTPTotalWidth = 0;
+        }
+
+        nTextPos += rTP.GetLen();
+        nTPTotalWidth += rTP.GetSize().getWidth();
+
+        aTmpFont.SetPhysFont(*GetRefDevice());
+        nTPMaxAscent = std::max(
+            nTPMaxAscent, 
static_cast<tools::Long>(GetRefDevice()->GetFontMetric().GetAscent()));
+
+        if (pTPRubyStart && nTextPos >= pNextRubyAttr->GetEnd())
+        {
+            auto pRubyInfo = std::make_unique<RubyPortionInfo>();
+
+            // Get ruby text width
+            const auto* pRuby = static_cast<const 
SvxRubyItem*>(pNextRubyAttr->GetItem());
+
+            // TODO: Style support is unimplemented. For now, use a hard-coded 
50% scale
+            aRubyStartFont.SetFontSize(aRubyStartFont.GetFontSize() / 2);
+            aRubyStartFont.SetPhysFont(*GetRefDevice());
+
+            auto aRubyMetrics = GetRefDevice()->GetFontMetric();
+            auto nRubyAscent = 
static_cast<tools::Long>(aRubyMetrics.GetAscent());
+
+            tools::Long nRubyWidth = aRubyStartFont
+                                         .QuickGetTextSize(GetRefDevice(), 
pRuby->GetText(), 0,
+                                                           
pRuby->GetText().getLength(),
+                                                           
/*pDXArray=*/nullptr, /*bStacked=*/false)
+                                         .Width();
+
+            switch (pRuby->GetAdjustment())
+            {
+                case css::text::RubyAdjust_LEFT:
+                    pRubyInfo->nXOffset = 0;
+                    break;
+
+                case css::text::RubyAdjust_RIGHT:
+                    pRubyInfo->nXOffset = nTPTotalWidth - nRubyWidth;
+                    break;
+
+                default:
+                case css::text::RubyAdjust_CENTER:
+                    pRubyInfo->nXOffset = (nTPTotalWidth - nRubyWidth) / 2;
+                    break;
+            }
+
+            switch (pRuby->GetPosition())
+            {
+                default:
+                case css::text::RubyPosition::ABOVE:
+                    pRubyInfo->nYOffset = nTPMaxAscent;
+                    pRubyInfo->nMaxAscent = nTPMaxAscent + nRubyAscent;
+                    break;
+
+                case css::text::RubyPosition::BELOW:
+                    pRubyInfo->nYOffset = -nRubyAscent;
+                    pRubyInfo->nMaxAscent = 0;
+                    break;
+            }
+
+            pTPRubyStart->SetRubyInfos(std::move(pRubyInfo));
+
+            pNextRubyAttr
+                = 
rParaPortion.GetNode()->GetCharAttribs().FindNextAttrib(EE_CHAR_RUBY, nTextPos);
+            pTPRubyStart = nullptr;
+        }
+    }
+}
+
 bool ImpEditEngine::CreateLines( sal_Int32 nPara, sal_uInt32 nStartPosY, bool 
bIsScaling )
 {
     assert(GetParaPortions().exists(nPara) && "Portion paragraph index is not 
valid");
@@ -1449,6 +1546,7 @@ bool ImpEditEngine::CreateLines( sal_Int32 nPara, 
sal_uInt32 nStartPosY, bool bI
 
         // Line finished => adjust
 
+        populateRubyInfo(rParaPortion, pLine);
 
         // CalcTextSize should be replaced by a continuous registering!
         Size aTextSize = pLine->CalcTextSize(rParaPortion);
@@ -1481,6 +1579,11 @@ bool ImpEditEngine::CreateLines( sal_Int32 nPara, 
sal_uInt32 nStartPosY, bool bI
                 aTmpFont.SetPhysFont(*GetRefDevice());
                 ImplInitDigitMode(*GetRefDevice(), aTmpFont.GetLanguage());
                 RecalcFormatterFontMetrics( aFormatterMetrics, aTmpFont );
+
+                if(const auto* pRubyInfo = rTP.GetRubyInfos(); pRubyInfo)
+                {
+                    aFormatterMetrics.nMaxAscent = 
std::max(aFormatterMetrics.nMaxAscent, pRubyInfo->nMaxAscent);
+                }
             }
             nTPos = nTPos + rTP.GetLen();
         }
@@ -2017,9 +2120,28 @@ void ImpEditEngine::ImpBreakLine(ParaPortion& 
rParaPortion, EditLine& rLine, Tex
 
             if (!maStatus.IsSingleLine())
             {
-                i18n::LineBreakResults aLBR = _xBI->getLineBreak(
-                    pNode->GetString(), nMaxBreakPos, aLocale, nMinBreakPos, 
aHyphOptions, aUserOptions );
-                nBreakPos = aLBR.breakIndex;
+                // Scan backwards for a valid break position
+                while (nMaxBreakPos > nMinBreakPos)
+                {
+                    i18n::LineBreakResults aLBR
+                        = _xBI->getLineBreak(pNode->GetString(), nMaxBreakPos, 
aLocale,
+                                             nMinBreakPos, aHyphOptions, 
aUserOptions);
+                    nBreakPos = aLBR.breakIndex;
+
+                    // Don't allow line breaks under ruby characters
+                    if (auto* pRubyAttr
+                        = 
pNode->GetCharAttribs().FindAttribRightOpen(EE_CHAR_RUBY, nBreakPos);
+                        pRubyAttr)
+                    {
+                        if (nBreakPos > pRubyAttr->GetStart() && nBreakPos < 
pRubyAttr->GetEnd())
+                        {
+                            nMaxBreakPos = pRubyAttr->GetStart();
+                            continue;
+                        }
+                    }
+
+                    break;
+                }
 
                 // show soft hyphen
                 if ( nBreakPos > 0 && CH_SOFTHYPHEN == 
pNode->GetString()[nBreakPos - 1] )
@@ -3444,6 +3566,44 @@ void ImpEditEngine::Paint( OutputDevice& rOutDev, 
tools::Rectangle aClipRect, Po
                         if (isXOverflowDirectionAware(aTmpPos, aClipRect))
                             break; // No further output in line necessary
 
+                        // Draw ruby characters, if present
+                        if (const auto* pRubyInfo = 
rTextPortion.GetRubyInfos(); pRubyInfo)
+                        {
+                            auto* pRubyAttr = 
rParaPortion.GetNode()->GetCharAttribs().FindAttrib(
+                                EE_CHAR_RUBY, nIndex);
+                            if (pRubyAttr && pRubyAttr->GetStart() == nIndex)
+                            {
+                                SeekCursor(rParaPortion.GetNode(), nIndex, 
aTmpFont, &rOutDev);
+
+                                const auto* pRuby
+                                    = static_cast<const 
SvxRubyItem*>(pRubyAttr->GetItem());
+                                if (rDrawPortion)
+                                {
+                                    const bool bEndOfLine(nPortion == 
pLine->GetEndPortion());
+                                    const bool bEndOfParagraph(bEndOfLine && 
nLine + 1 == nLines);
+
+                                    const Color 
aOverlineColor(rOutDev.GetOverlineColor());
+                                    const Color 
aTextLineColor(rOutDev.GetTextLineColor());
+
+                                    Point aRubyPos = aTmpPos;
+                                    aRubyPos.AdjustX(pRubyInfo->nXOffset);
+                                    aRubyPos.AdjustY(-pRubyInfo->nYOffset);
+
+                                    auto nPrevSz = aTmpFont.GetFontSize();
+                                    aTmpFont.SetFontSize(nPrevSz / 2);
+
+                                    const DrawPortionInfo aInfo(
+                                        aRubyPos, pRuby->GetText(), 0, 
pRuby->GetText().getLength(),
+                                        {}, {}, aTmpFont, nParaPortion, 0, 
nullptr, nullptr,
+                                        bEndOfLine, bEndOfParagraph, false, 
nullptr, aOverlineColor,
+                                        aTextLineColor);
+                                    rDrawPortion(aInfo);
+
+                                    aTmpFont.SetFontSize(nPrevSz);
+                                }
+                            }
+                        }
+
                         switch ( rTextPortion.GetKind() )
                         {
                             case PortionKind::TEXT:
diff --git a/editeng/source/items/rubyitem.cxx 
b/editeng/source/items/rubyitem.cxx
new file mode 100644
index 000000000000..76d8ecab701e
--- /dev/null
+++ b/editeng/source/items/rubyitem.cxx
@@ -0,0 +1,134 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/*
+ * This file is part of the LibreOffice project.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * This file incorporates work covered by the following license notice:
+ *
+ *   Licensed to the Apache Software Foundation (ASF) under one or more
+ *   contributor license agreements. See the NOTICE file distributed
+ *   with this work for additional information regarding copyright
+ *   ownership. The ASF licenses this file to you under the Apache
+ *   License, Version 2.0 (the "License"); you may not use this file
+ *   except in compliance with the License. You may obtain a copy of
+ *   the License at http://www.apache.org/licenses/LICENSE-2.0 .
+ */
+
+#include <editeng/memberids.h>
+#include <editeng/rubyitem.hxx>
+
+using namespace ::com::sun::star;
+
+SfxPoolItem* SvxRubyItem::CreateDefault() { return new SvxRubyItem(0); }
+
+SvxRubyItem::SvxRubyItem(const sal_uInt16 nId)
+    : SfxPoolItem(nId)
+{
+}
+
+const OUString& SvxRubyItem::GetText() const { return m_aText; }
+
+void SvxRubyItem::SetText(OUString aValue)
+{
+    ASSERT_CHANGE_REFCOUNTED_ITEM;
+    m_aText = std::move(aValue);
+}
+
+css::text::RubyAdjust SvxRubyItem::GetAdjustment() const { return 
m_eAdjustment; }
+
+void SvxRubyItem::SetAdjustment(css::text::RubyAdjust eValue)
+{
+    ASSERT_CHANGE_REFCOUNTED_ITEM;
+    m_eAdjustment = eValue;
+}
+
+sal_Int16 SvxRubyItem::GetPosition() const { return m_ePosition; }
+
+void SvxRubyItem::SetPosition(sal_Int16 eValue)
+{
+    ASSERT_CHANGE_REFCOUNTED_ITEM;
+    m_ePosition = eValue;
+}
+
+bool SvxRubyItem::operator==(const SfxPoolItem& rItem) const
+{
+    assert(SfxPoolItem::operator==(rItem));
+    const auto& rCastItem = static_cast<const SvxRubyItem&>(rItem);
+
+    return std::tie(m_aText, m_eAdjustment, m_ePosition)
+           == std::tie(rCastItem.m_aText, rCastItem.m_eAdjustment, 
rCastItem.m_ePosition);
+}
+
+SvxRubyItem* SvxRubyItem::Clone(SfxItemPool*) const { return new 
SvxRubyItem(*this); }
+
+bool SvxRubyItem::GetPresentation(SfxItemPresentation /*ePres*/, MapUnit 
/*eCoreUnit*/,
+                                  MapUnit /*ePresUnit*/, OUString& rText,
+                                  const IntlWrapper& /*rIntl*/
+                                  ) const
+{
+    rText = m_aText;
+    return true;
+}
+
+bool SvxRubyItem::QueryValue(uno::Any& rVal, sal_uInt8 nMemberId) const
+{
+    nMemberId &= ~CONVERT_TWIPS;
+    switch (nMemberId)
+    {
+        case MID_RUBY_TEXT:
+            rVal <<= m_aText;
+            return true;
+
+        case MID_RUBY_ADJUST:
+            rVal <<= static_cast<sal_Int16>(m_eAdjustment);
+            return true;
+
+        case MID_RUBY_POSITION:
+            rVal <<= m_ePosition;
+            return true;
+    }
+
+    return false;
+}
+
+bool SvxRubyItem::PutValue(const uno::Any& rVal, sal_uInt8 nMemberId)
+{
+    nMemberId &= ~CONVERT_TWIPS;
+    switch (nMemberId)
+    {
+        case MID_RUBY_TEXT:
+            if (OUString aValue; rVal >>= aValue)
+            {
+                SetText(std::move(aValue));
+                return true;
+            }
+            break;
+
+        case MID_RUBY_ADJUST:
+            if (sal_Int16 eValue; rVal >>= eValue)
+            {
+                if (eValue >= sal_Int16(css::text::RubyAdjust_LEFT)
+                    && eValue <= sal_Int16(css::text::RubyAdjust_INDENT_BLOCK))
+                {
+                    SetAdjustment(css::text::RubyAdjust{ eValue });
+                    return true;
+                }
+            }
+            break;
+
+        case MID_RUBY_POSITION:
+            if (sal_Int16 eValue; rVal >>= eValue)
+            {
+                SetPosition(eValue);
+                return true;
+            }
+            break;
+    }
+
+    return false;
+}
+
+/* vim:set shiftwidth=4 softtabstop=4 expandtab: */
diff --git a/editeng/source/items/textitem.cxx 
b/editeng/source/items/textitem.cxx
index 5adfabe97c72..56923526cff1 100644
--- a/editeng/source/items/textitem.cxx
+++ b/editeng/source/items/textitem.cxx
@@ -76,6 +76,7 @@
 #include <editeng/charrotateitem.hxx>
 #include <editeng/charscaleitem.hxx>
 #include <editeng/charreliefitem.hxx>
+#include <editeng/rubyitem.hxx>
 #include <editeng/itemtype.hxx>
 #include <editeng/eerdll.hxx>
 #include <docmodel/color/ComplexColorJSON.hxx>
diff --git a/include/editeng/editids.hrc b/include/editeng/editids.hrc
index 9ff4884fbcbc..84e8aab8ed2a 100644
--- a/include/editeng/editids.hrc
+++ b/include/editeng/editids.hrc
@@ -55,6 +55,7 @@ class SvxOverlineItem;
 class SvxPageModelItem;
 class SvxParaVertAlignItem;
 class SvxPostureItem;
+class SvxRubyItem;
 class SvxScriptSpaceItem;
 class SvxShadowItem;
 class SvxShadowedItem;
@@ -149,6 +150,7 @@ class SvxWordLineModeItem;
 #define SID_ATTR_PARA_SCRIPTSPACE                       
TypedWhichId<SvxScriptSpaceItem>( SID_SVX_START + 901 )
 #define SID_ATTR_PARA_HANGPUNCTUATION                   
TypedWhichId<SvxHangingPunctuationItem>( SID_SVX_START + 902 )
 #define SID_ATTR_PARA_FORBIDDEN_RULES                   
TypedWhichId<SvxForbiddenRuleItem>( SID_SVX_START + 903 )
+#define SID_ATTR_CHAR_RUBY                              
TypedWhichId<SvxRubyItem>( SID_SVX_START + 904 )
 #define SID_ATTR_CHAR_VERTICAL                          ( SID_SVX_START + 905 )
 #define SID_ATTR_CHAR_ROTATED                           
TypedWhichId<SvxCharRotateItem>( SID_SVX_START + 910 )
 #define SID_ATTR_CHAR_SCALEWIDTH                        
TypedWhichId<SvxCharScaleWidthItem>( SID_SVX_START + 911 )
diff --git a/include/editeng/eeitem.hxx b/include/editeng/eeitem.hxx
index f8c3d585fc24..3bbc30a869f7 100644
--- a/include/editeng/eeitem.hxx
+++ b/include/editeng/eeitem.hxx
@@ -60,6 +60,7 @@ class SvxBulletItem;
 class SvxNumBulletItem;
 class SvxJustifyMethodItem;
 class SvxVerJustifyItem;
+class SvxRubyItem;
 
 /*
  * NOTE: Changes in this file will probably require
@@ -127,8 +128,9 @@ inline constexpr TypedWhichId<SvxOverlineItem>        
EE_CHAR_OVERLINE       (EE
 inline constexpr TypedWhichId<SvxCaseMapItem>         EE_CHAR_CASEMAP        
(EE_CHAR_START+29);
 inline constexpr TypedWhichId<SfxGrabBagItem>         EE_CHAR_GRABBAG        
(EE_CHAR_START+30);
 inline constexpr TypedWhichId<SvxColorItem>           EE_CHAR_BKGCOLOR       
(EE_CHAR_START+31);
+inline constexpr TypedWhichId<SvxRubyItem>            EE_CHAR_RUBY           
(EE_CHAR_START+32);
 
-inline constexpr sal_uInt16                           EE_CHAR_END            
(EE_CHAR_START + 31);
+inline constexpr sal_uInt16                           EE_CHAR_END            
(EE_CHAR_START + 32);
 
 inline constexpr sal_uInt16 EE_FEATURE_START   (EE_CHAR_END + 1);
 inline constexpr sal_uInt16 EE_FEATURE_TAB     (EE_FEATURE_START + 0);
diff --git a/include/editeng/memberids.h b/include/editeng/memberids.h
index 7748e8167750..2b75123d94d8 100644
--- a/include/editeng/memberids.h
+++ b/include/editeng/memberids.h
@@ -212,6 +212,12 @@
 #define MID_COMPLEX_COLOR_JSON  8
 #define MID_COMPLEX_COLOR       9
 
+// SvxRubyItem and SwFormatRuby
+#define MID_RUBY_TEXT           0
+#define MID_RUBY_ADJUST         1
+#define MID_RUBY_CHARSTYLE      2
+#define MID_RUBY_ABOVE          3
+#define MID_RUBY_POSITION       4
 
 #endif
 
diff --git a/include/editeng/rubyitem.hxx b/include/editeng/rubyitem.hxx
new file mode 100644
index 000000000000..3c8b88ea2c3d
--- /dev/null
+++ b/include/editeng/rubyitem.hxx
@@ -0,0 +1,59 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/*
+ * This file is part of the LibreOffice project.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * This file incorporates work covered by the following license notice:
+ *
+ *   Licensed to the Apache Software Foundation (ASF) under one or more
+ *   contributor license agreements. See the NOTICE file distributed
+ *   with this work for additional information regarding copyright
+ *   ownership. The ASF licenses this file to you under the Apache
+ *   License, Version 2.0 (the "License"); you may not use this file
+ *   except in compliance with the License. You may obtain a copy of
+ *   the License at http://www.apache.org/licenses/LICENSE-2.0 .
+ */
+#pragma once
+
+#include <svl/poolitem.hxx>
+#include <editeng/editengdllapi.h>
+#include <com/sun/star/text/RubyAdjust.hpp>
+#include <com/sun/star/text/RubyPosition.hpp>
+
+class EDITENG_DLLPUBLIC SvxRubyItem : public SfxPoolItem
+{
+    OUString m_aText;
+    css::text::RubyAdjust m_eAdjustment = 
css::text::RubyAdjust::RubyAdjust_LEFT;
+    sal_Int16 m_ePosition = css::text::RubyPosition::ABOVE;
+
+public:
+    static SfxPoolItem* CreateDefault();
+
+    DECLARE_ITEM_TYPE_FUNCTION(SvxRubyItem)
+    explicit SvxRubyItem(const sal_uInt16 nId);
+
+    const OUString& GetText() const;
+    void SetText(OUString aValue);
+
+    css::text::RubyAdjust GetAdjustment() const;
+    void SetAdjustment(css::text::RubyAdjust eValue);
+
+    sal_Int16 GetPosition() const;
+    void SetPosition(sal_Int16 eValue);
+
+    // "pure virtual Methods" from SfxPoolItem
+    virtual bool operator==(const SfxPoolItem&) const override;
+    virtual bool QueryValue(css::uno::Any& rVal, sal_uInt8 nMemberId = 0) 
const override;
+    virtual bool PutValue(const css::uno::Any& rVal, sal_uInt8 nMemberId) 
override;
+
+    virtual bool GetPresentation(SfxItemPresentation ePres, MapUnit 
eCoreMetric,
+                                 MapUnit ePresMetric, OUString& rText,
+                                 const IntlWrapper&) const override;
+
+    virtual SvxRubyItem* Clone(SfxItemPool* pPool = nullptr) const override;
+};
+
+/* vim:set shiftwidth=4 softtabstop=4 expandtab: */
diff --git a/include/editeng/unoprnms.hxx b/include/editeng/unoprnms.hxx
index b42c03bca762..38c6525af39b 100644
--- a/include/editeng/unoprnms.hxx
+++ b/include/editeng/unoprnms.hxx
@@ -359,6 +359,10 @@ inline constexpr OUString 
UNO_NAME_EDIT_CHAR_BACKGROUND_COLOR = u"CharBackColor"
 inline constexpr OUString UNO_NAME_EDIT_CHAR_BACKGROUND_COMPLEX_COLOR = 
u"CharBackgroundComplexColor"_ustr;
 inline constexpr OUString UNO_NAME_EDIT_CHAR_BACKGROUND_TRANSPARENT = 
u"CharBackTransparent"_ustr;
 
+inline constexpr OUString UNO_NAME_EDIT_CHAR_RUBY_TEXT = u"RubyText"_ustr;
+inline constexpr OUString UNO_NAME_EDIT_CHAR_RUBY_ADJUST = u"RubyAdjust"_ustr;
+inline constexpr OUString UNO_NAME_EDIT_CHAR_RUBY_POSITION = 
u"RubyPosition"_ustr;
+
 inline constexpr OUString UNO_NAME_BITMAP = u"Bitmap"_ustr;
 
 inline constexpr OUString UNO_NAME_LINKDISPLAYNAME = u"LinkDisplayName"_ustr;
diff --git a/include/editeng/unotext.hxx b/include/editeng/unotext.hxx
index b411867c5b0a..64d53a97f999 100644
--- a/include/editeng/unotext.hxx
+++ b/include/editeng/unotext.hxx
@@ -133,8 +133,11 @@ struct SfxItemPropertyMapEntry;
     { UNO_NAME_EDIT_CHAR_POSTURE_COMPLEX,     EE_CHAR_ITALIC_CTL,     
::cppu::UnoType<css::awt::FontSlant>::get(),0, MID_POSTURE }, \
     { UNO_NAME_EDIT_CHAR_WEIGHT_COMPLEX,      EE_CHAR_WEIGHT_CTL,     
cppu::UnoType<float>::get(),            0, MID_WEIGHT }, \
     { UNO_NAME_EDIT_CHAR_LOCALE_COMPLEX,      EE_CHAR_LANGUAGE_CTL,   
::cppu::UnoType<css::lang::Locale>::get(),0, MID_LANG_LOCALE }, \
-    { u"CharRelief"_ustr,                      EE_CHAR_RELIEF,         
::cppu::UnoType<sal_Int16>::get(),    0, MID_RELIEF }, \
-    { u"CharInteropGrabBag"_ustr,              EE_CHAR_GRABBAG,        
cppu::UnoType<css::uno::Sequence<css::beans::PropertyValue >>::get(), 0, 0}
+    { u"CharRelief"_ustr,                     EE_CHAR_RELIEF,         
::cppu::UnoType<sal_Int16>::get(),    0, MID_RELIEF }, \
+    { u"CharInteropGrabBag"_ustr,             EE_CHAR_GRABBAG,        
cppu::UnoType<css::uno::Sequence<css::beans::PropertyValue >>::get(), 0, 0 }, \
+    { UNO_NAME_EDIT_CHAR_RUBY_TEXT,           EE_CHAR_RUBY,           
::cppu::UnoType<OUString>::get(), 0, MID_RUBY_TEXT }, \
+    { UNO_NAME_EDIT_CHAR_RUBY_ADJUST,         EE_CHAR_RUBY,           
::cppu::UnoType<sal_Int16>::get(), 0, MID_RUBY_ADJUST }, \
+    { UNO_NAME_EDIT_CHAR_RUBY_POSITION,       EE_CHAR_RUBY,           
::cppu::UnoType<sal_Int16>::get(), 0, MID_RUBY_POSITION }
 
 
 #define SVX_UNOEDIT_FONT_PROPERTIES \
diff --git a/include/svl/poolitem.hxx b/include/svl/poolitem.hxx
index f63d14494473..259f1ac1cc06 100644
--- a/include/svl/poolitem.hxx
+++ b/include/svl/poolitem.hxx
@@ -399,6 +399,7 @@ enum class SfxItemType : sal_uInt16
     SvxRightMarginItemType,
     SvxRotateModeItemType,
     SvxRsidItemType,
+    SvxRubyItemType,
     SvxScriptSetItemType,
     SvxScriptSpaceItemType,
     SvxSearchItemType,
diff --git a/sw/inc/unomid.h b/sw/inc/unomid.h
index 9f413509ae1c..7d7cdb4c9af6 100644
--- a/sw/inc/unomid.h
+++ b/sw/inc/unomid.h
@@ -109,13 +109,6 @@
 #define MID_LINE_FOOTNOTE_DIST                  6
 #define MID_FTN_LINE_STYLE                      7
 
-//SwFormatRuby
-#define MID_RUBY_TEXT           0
-#define MID_RUBY_ADJUST         1
-#define MID_RUBY_CHARSTYLE      2
-#define MID_RUBY_ABOVE          3
-#define MID_RUBY_POSITION       4
-
 //SwTextGridItem
 #define MID_GRID_COLOR          0
 #define MID_GRID_LINES          1
diff --git a/sw/source/core/txtnode/fmtatr2.cxx 
b/sw/source/core/txtnode/fmtatr2.cxx
index 4a28121d2e20..cff6ff2a1235 100644
--- a/sw/source/core/txtnode/fmtatr2.cxx
+++ b/sw/source/core/txtnode/fmtatr2.cxx
@@ -21,6 +21,7 @@
 #include <hintids.hxx>
 #include <poolfmt.hxx>
 #include <unomid.h>
+#include <editeng/memberids.h>
 
 #include <o3tl/any.hxx>
 #include <svl/macitem.hxx>
diff --git a/sw/source/core/unocore/unoobj.cxx 
b/sw/source/core/unocore/unoobj.cxx
index 70a7275e3f06..bdcaebdb7f2a 100644
--- a/sw/source/core/unocore/unoobj.cxx
+++ b/sw/source/core/unocore/unoobj.cxx
@@ -25,6 +25,7 @@
 #include <o3tl/safeint.hxx>
 #include <osl/endian.h>
 #include <unotools/collatorwrapper.hxx>
+#include <editeng/memberids.h>
 
 #include <autostyle_helper.hxx>
 #include <swtypes.hxx>
diff --git a/sw/source/core/unocore/unoport.cxx 
b/sw/source/core/unocore/unoport.cxx
index 1ba9d3416d1c..86ba75fc7280 100644
--- a/sw/source/core/unocore/unoport.cxx
+++ b/sw/source/core/unocore/unoport.cxx
@@ -36,6 +36,7 @@
 #include <ndtxt.hxx>
 #include <doc.hxx>
 #include <frmfmt.hxx>
+#include <editeng/memberids.h>
 
 #include <com/sun/star/beans/PropertyAttribute.hpp>
 #include <com/sun/star/beans/SetPropertyTolerantFailed.hpp>
diff --git a/vcl/qa/cppunit/pdfexport/data/textbox-ruby.fodt 
b/vcl/qa/cppunit/pdfexport/data/textbox-ruby.fodt
new file mode 100644
index 000000000000..eebc992d3d1b
--- /dev/null
+++ b/vcl/qa/cppunit/pdfexport/data/textbox-ruby.fodt
@@ -0,0 +1,149 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<office:document xmlns:css3t="http://www.w3.org/TR/css3-text/"; 
xmlns:grddl="http://www.w3.org/2003/g/data-view#"; 
xmlns:xhtml="http://www.w3.org/1999/xhtml"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xmlns:xsd="http://www.w3.org/2001/XMLSchema"; 
xmlns:xforms="http://www.w3.org/2002/xforms"; 
xmlns:dom="http://www.w3.org/2001/xml-events"; 
xmlns:script="urn:oasis:names:tc:opendocument:xmlns:script:1.0" 
xmlns:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0" 
xmlns:math="http://www.w3.org/1998/Math/MathML"; 
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" 
xmlns:ooo="http://openoffice.org/2004/office"; 
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" 
xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0" 
xmlns:ooow="http://openoffice.org/2004/writer"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; 
xmlns:drawooo="http://openoffice.org/2010/draw"; 
xmlns:oooc="http://openoffice.org/2004/calc"; 
xmlns:dc="http://purl.org/dc/elements/1.1/"; xmlns:c
 alcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" 
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" 
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" 
xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2" 
xmlns:tableooo="http://openoffice.org/2009/table"; 
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" 
xmlns:dr3d="urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0" 
xmlns:rpt="http://openoffice.org/2005/report"; 
xmlns:formx="urn:openoffice:names:experimental:ooxml-odf-interop:xmlns:form:1.0"
 xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" 
xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" 
xmlns:officeooo="http://openoffice.org/2009/office"; 
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" 
xmlns:field="urn:openoffice:names:experimental:ooo-ms-interop:xmlns:field:1.0" 
xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" 
xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:
 meta:1.0" 
xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0"
 office:version="1.4" office:mimetype="application/vnd.oasis.opendocument.text">
+ 
<office:meta><meta:creation-date>2025-05-12T03:30:59.696271370</meta:creation-date><dc:date>2025-05-12T03:36:21.248814271</dc:date><meta:editing-duration>PT3M43S</meta:editing-duration><meta:editing-cycles>6</meta:editing-cycles><meta:generator>LibreOfficeDev/25.8.0.0.alpha0$Linux_X86_64
 
LibreOffice_project/6ffaeff548eaeef29e41adbc3c923d40c0e77a78</meta:generator><meta:document-statistic
 meta:table-count="0" meta:image-count="0" meta:object-count="0" 
meta:page-count="1" meta:paragraph-count="6" meta:word-count="22" 
meta:character-count="146" 
meta:non-whitespace-character-count="130"/></office:meta>
+ <office:font-face-decls>
+  <style:font-face style:name="Liberation Serif" svg:font-family="'Liberation 
Serif'" style:font-family-generic="roman" style:font-pitch="variable"/>
+  <style:font-face style:name="Noto Sans1" svg:font-family="'Noto Sans'" 
style:font-family-generic="system" style:font-pitch="variable"/>
+  <style:font-face style:name="Noto Serif CJK SC" svg:font-family="'Noto Serif 
CJK SC'" style:font-family-generic="system" style:font-pitch="variable"/>
+ </office:font-face-decls>
+ <office:styles>
+  <style:default-style style:family="graphic">
+   <style:graphic-properties svg:stroke-color="#3465a4" 
draw:fill-color="#729fcf" fo:wrap-option="no-wrap" 
draw:shadow-offset-x="0.1181in" draw:shadow-offset-y="0.1181in" 
draw:start-line-spacing-horizontal="0.1114in" 
draw:start-line-spacing-vertical="0.1114in" 
draw:end-line-spacing-horizontal="0.1114in" 
draw:end-line-spacing-vertical="0.1114in" style:writing-mode="lr-tb" 
style:flow-with-text="false"/>
+   <style:paragraph-properties style:text-autospace="ideograph-alpha" 
style:line-break="strict" loext:tab-stop-distance="0in" 
style:font-independent-line-spacing="false">
+    <style:tab-stops/>
+   </style:paragraph-properties>
+   <style:text-properties style:use-window-font-color="true" 
loext:opacity="0%" style:font-name="Liberation Serif" fo:font-size="12pt" 
fo:language="en" fo:country="US" style:letter-kerning="true" 
style:font-name-asian="Noto Serif CJK SC" style:font-size-asian="10.5pt" 
style:language-asian="zh" style:country-asian="CN" 
style:font-name-complex="Noto Sans1" style:font-size-complex="12pt" 
style:language-complex="hi" style:country-complex="IN"/>
+  </style:default-style>
+  <style:default-style style:family="paragraph">
+   <style:paragraph-properties fo:orphans="2" fo:widows="2" 
fo:hyphenation-ladder-count="no-limit" fo:hyphenation-keep="auto" 
loext:hyphenation-keep-type="column" loext:hyphenation-keep-line="false" 
style:text-autospace="ideograph-alpha" style:punctuation-wrap="hanging" 
style:line-break="strict" style:tab-stop-distance="0.4925in" 
style:writing-mode="page"/>
+   <style:text-properties style:use-window-font-color="true" 
loext:opacity="0%" style:font-name="Liberation Serif" fo:font-size="12pt" 
fo:language="en" fo:country="US" style:letter-kerning="true" 
style:font-name-asian="Noto Serif CJK SC" style:font-size-asian="10.5pt" 
style:language-asian="zh" style:country-asian="CN" 
style:font-name-complex="Noto Sans1" style:font-size-complex="12pt" 
style:language-complex="hi" style:country-complex="IN" fo:hyphenate="false" 
fo:hyphenation-remain-char-count="2" fo:hyphenation-push-char-count="2" 
loext:hyphenation-no-caps="false" loext:hyphenation-no-last-word="false" 
loext:hyphenation-word-char-count="5" loext:hyphenation-zone="no-limit"/>
+  </style:default-style>
+  <style:default-style style:family="table">
+   <style:table-properties table:border-model="collapsing"/>
+  </style:default-style>
+  <style:default-style style:family="table-row">
+   <style:table-row-properties fo:keep-together="auto"/>
+  </style:default-style>
+  <style:style style:name="Standard" style:family="paragraph" 
style:class="text"/>
+  <text:outline-style style:name="Outline">
+   <text:outline-level-style text:level="1" loext:num-list-format="%1%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="2" loext:num-list-format="%2%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="3" loext:num-list-format="%3%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="4" loext:num-list-format="%4%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="5" loext:num-list-format="%5%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="6" loext:num-list-format="%6%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="7" loext:num-list-format="%7%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="8" loext:num-list-format="%8%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="9" loext:num-list-format="%9%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+   <text:outline-level-style text:level="10" loext:num-list-format="%10%" 
style:num-format="">
+    <style:list-level-properties 
text:list-level-position-and-space-mode="label-alignment">
+     <style:list-level-label-alignment text:label-followed-by="listtab"/>
+    </style:list-level-properties>
+   </text:outline-level-style>
+  </text:outline-style>
+  <text:notes-configuration text:note-class="footnote" style:num-format="1" 
text:start-value="0" text:footnotes-position="page" 
text:start-numbering-at="document"/>
+  <text:notes-configuration text:note-class="endnote" style:num-format="i" 
text:start-value="0"/>
+  <text:linenumbering-configuration text:number-lines="false" 
text:offset="0.1965in" style:num-format="1" text:number-position="left" 
text:increment="5"/>
+  </office:styles>
+ <office:automatic-styles>
+  <style:style style:name="P1" style:family="paragraph" 
style:parent-style-name="Standard">
+   <style:text-properties/>
+  </style:style>
+  <style:style style:name="P2" style:family="paragraph" 
style:parent-style-name="Standard">
+   <style:text-properties/>
+  </style:style>
+  <style:style style:name="P3" style:family="paragraph">
+   <loext:graphic-properties draw:fill="none" draw:fill-color="#ffffff"/>
+  </style:style>
+  <style:style style:name="T1" style:family="text">
+   <style:text-properties/>
+  </style:style>
+  <style:style style:name="Ru1" style:family="ruby">
+   <style:ruby-properties style:ruby-align="left" style:ruby-position="above" 
loext:ruby-position="above"/>
+  </style:style>
+  <style:style style:name="Ru2" style:family="ruby">
+   <style:ruby-properties style:ruby-align="center" 
style:ruby-position="above" loext:ruby-position="above"/>
+  </style:style>
+  <style:style style:name="Ru3" style:family="ruby">
+   <style:ruby-properties style:ruby-align="right" style:ruby-position="above" 
loext:ruby-position="above"/>
+  </style:style>
+  <style:style style:name="Ru4" style:family="ruby">
+   <style:ruby-properties style:ruby-align="center" 
style:ruby-position="below" loext:ruby-position="below"/>
+  </style:style>
+  <style:style style:name="gr1" style:family="graphic">
+   <style:graphic-properties draw:stroke="none" svg:stroke-color="#000000" 
draw:fill="none" draw:fill-color="#ffffff" fo:min-height="2.7638in" 
loext:decorative="false" style:run-through="foreground" 
style:wrap="run-through" style:number-wrapped-paragraphs="no-limit" 
style:vertical-pos="from-top" style:vertical-rel="paragraph" 
style:horizontal-pos="from-left" style:horizontal-rel="paragraph" 
draw:wrap-influence-on-position="once-concurrent" loext:allow-overlap="true" 
style:flow-with-text="false"/>
+   <style:paragraph-properties style:writing-mode="lr-tb"/>
+  </style:style>
+  <style:page-layout style:name="pm1">
+   <style:page-layout-properties fo:page-width="8.2681in" 
fo:page-height="11.6929in" style:num-format="1" 
style:print-orientation="portrait" fo:margin-top="0.7874in" 
fo:margin-bottom="0.7874in" fo:margin-left="0.7874in" 
fo:margin-right="0.7874in" style:writing-mode="lr-tb" 
style:layout-grid-color="#c0c0c0" style:layout-grid-lines="20" 
style:layout-grid-base-height="0.278in" style:layout-grid-ruby-height="0.139in" 
style:layout-grid-mode="none" style:layout-grid-ruby-below="false" 
style:layout-grid-print="false" style:layout-grid-display="false" 
style:footnote-max-height="0in" loext:margin-gutter="0in">
+    <style:footnote-sep style:width="0.0071in" 
style:distance-before-sep="0.0398in" style:distance-after-sep="0.0398in" 
style:line-style="solid" style:adjustment="left" style:rel-width="25%" 
style:color="#000000"/>
+   </style:page-layout-properties>
+   <style:header-style/>
+   <style:footer-style/>
+  </style:page-layout>
+  <style:style style:name="dp1" style:family="drawing-page">
+   <style:drawing-page-properties draw:background-size="full"/>
+  </style:style>
+ </office:automatic-styles>
+ <office:master-styles>
+  <style:master-page style:name="Standard" style:page-layout-name="pm1" 
draw:style-name="dp1"/>
+ </office:master-styles>
+ <office:body>
+  <office:text>
+   <text:sequence-decls>
+    <text:sequence-decl text:display-outline-level="0" 
text:name="Illustration"/>
+    <text:sequence-decl text:display-outline-level="0" text:name="Table"/>
+    <text:sequence-decl text:display-outline-level="0" text:name="Text"/>
+    <text:sequence-decl text:display-outline-level="0" text:name="Drawing"/>
+    <text:sequence-decl text:display-outline-level="0" text:name="Figure"/>
+   </text:sequence-decls>
+   <text:p text:style-name="P1">Prototype test for ruby characters in Edit 
Engine</text:p>
+   <text:p text:style-name="P1"/>
+   <text:p text:style-name="P2"><draw:frame text:anchor-type="paragraph" 
draw:z-index="0" draw:name="Text Frame 1" draw:style-name="gr1" 
draw:text-style-name="P3" svg:width="2.0976in" svg:height="2.7642in" 
svg:x="0.6799in" svg:y="0.3083in">
+     <draw:text-box>
+   <text:p text:style-name="P1">Left-aligned: <text:ruby 
text:style-name="Ru1"><text:ruby-base>BASE</text:ruby-base><text:ruby-text>top1</text:ruby-text></text:ruby></text:p>
+   <text:p text:style-name="P1">Centered: <text:ruby 
text:style-name="Ru2"><text:ruby-base>BASE</text:ruby-base><text:ruby-text>top2</text:ruby-text></text:ruby></text:p>
+   <text:p text:style-name="P1">Right-aligned: <text:ruby 
text:style-name="Ru3"><text:ruby-base>BASE</text:ruby-base><text:ruby-text>top3</text:ruby-text></text:ruby></text:p>
+   <text:p text:style-name="P1">Below: <text:ruby 
text:style-name="Ru4"><text:ruby-base>BASE</text:ruby-base><text:ruby-text>top4</text:ruby-text></text:ruby></text:p>
+   <text:p text:style-name="P2"><text:span text:style-name="T1">Line wrapped: 
other </text:span><text:ruby text:style-name="Ru2"><text:ruby-base><text:span 
text:style-name="T1">BASE 
BASE</text:span></text:ruby-base><text:ruby-text>top5</text:ruby-text></text:ruby><text:span
 text:style-name="T1"> other</text:span></text:p>
+     </draw:text-box>
+    </draw:frame></text:p>
+  </office:text>
+ </office:body>
+</office:document>
\ No newline at end of file
diff --git a/vcl/qa/cppunit/pdfexport/pdfexport2.cxx 
b/vcl/qa/cppunit/pdfexport/pdfexport2.cxx
index 1dc24cb8aeca..f22fb4806b0f 100644
--- a/vcl/qa/cppunit/pdfexport/pdfexport2.cxx
+++ b/vcl/qa/cppunit/pdfexport/pdfexport2.cxx
@@ -6241,6 +6241,95 @@ CPPUNIT_TEST_FIXTURE(PdfExportTest2, 
testPDFAttachmentsWithEncryptedFile)
     CPPUNIT_ASSERT_EQUAL(u"This is a test document."_ustr, 
xParagraph->getString());
 }
 
+CPPUNIT_TEST_FIXTURE(PdfExportTest2, testTextBoxRuby)
+{
+    // This test exercises a work-in-progress Edit Engine ruby feature.
+    // It is expected that this test will fail and need to be updated
+    // as the feature is refined.
+
+    saveAsPDF(u"textbox-ruby.fodt");
+
+    auto pPdfDocument = parsePDFExport();
+    CPPUNIT_ASSERT_EQUAL(1, pPdfDocument->getPageCount());
+
+    auto pPdfPage = pPdfDocument->openPage(/*nIndex*/ 0);
+    CPPUNIT_ASSERT(pPdfPage);
+    auto pTextPage = pPdfPage->getTextPage();
+    CPPUNIT_ASSERT(pTextPage);
+
+    int nPageObjectCount = pPdfPage->getObjectCount();
+
+    CPPUNIT_ASSERT_EQUAL(17, nPageObjectCount);
+
+    std::vector<OUString> aText;
+    std::vector<basegfx::B2DRectangle> aRect;
+
+    for (int i = 0; i < nPageObjectCount; ++i)
+    {
+        auto pPageObject = pPdfPage->getObject(i);
+        CPPUNIT_ASSERT_MESSAGE("no object", pPageObject != nullptr);
+        if (pPageObject->getType() == vcl::pdf::PDFPageObjectType::Text)
+        {
+            aText.push_back(pPageObject->getText(pTextPage));
+            aRect.push_back(pPageObject->getBounds());
+        }
+    }
+
+    CPPUNIT_ASSERT_EQUAL(size_t(17), aText.size());
+
+    // Lines from the Writer portion
+    CPPUNIT_ASSERT_EQUAL(u"Prototype test for ruby characters in Edit 
Engine"_ustr,
+                         aText.at(0).trim());
+
+    // Lines from the Edit Engine portion
+    CPPUNIT_ASSERT_EQUAL(u"Left-aligned:"_ustr, aText.at(1).trim());
+
+    CPPUNIT_ASSERT_EQUAL(u"top1"_ustr, aText.at(2).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(171.0, aRect.at(2).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(734.0, aRect.at(2).getMaxY(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_EQUAL(u"BASE"_ustr, aText.at(3).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(171.0, aRect.at(3).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(719.0, aRect.at(3).getMinY(), /*delta*/ 5.0);
+
+    CPPUNIT_ASSERT_EQUAL(u"Centered:"_ustr, aText.at(4).trim());
+
+    CPPUNIT_ASSERT_EQUAL(u"top2"_ustr, aText.at(5).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(165.0, aRect.at(5).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(715.0, aRect.at(5).getMaxY(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_EQUAL(u"BASE"_ustr, aText.at(6).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(156.0, aRect.at(6).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(701.0, aRect.at(6).getMinY(), /*delta*/ 5.0);
+
+    CPPUNIT_ASSERT_EQUAL(u"Right-aligned:"_ustr, aText.at(7).trim());
+
+    CPPUNIT_ASSERT_EQUAL(u"top3"_ustr, aText.at(8).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(198.0, aRect.at(8).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(697.0, aRect.at(8).getMaxY(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_EQUAL(u"BASE"_ustr, aText.at(9).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(178.0, aRect.at(9).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(682.0, aRect.at(9).getMinY(), /*delta*/ 5.0);
+
+    CPPUNIT_ASSERT_EQUAL(u"Below:"_ustr, aText.at(10).trim());
+
+    CPPUNIT_ASSERT_EQUAL(u"top4"_ustr, aText.at(11).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(153.0, aRect.at(11).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(667.0, aRect.at(11).getMaxY(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_EQUAL(u"BASE"_ustr, aText.at(12).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(144.0, aRect.at(12).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(668.0, aRect.at(12).getMinY(), /*delta*/ 5.0);
+
+    CPPUNIT_ASSERT_EQUAL(u"Line wrapped: other"_ustr, aText.at(13).trim());
+
+    CPPUNIT_ASSERT_EQUAL(u"top5"_ustr, aText.at(14).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(133.0, aRect.at(14).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(650.0, aRect.at(14).getMaxY(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_EQUAL(u"BASE BASE"_ustr, aText.at(15).trim());
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(106.0, aRect.at(15).getMinX(), /*delta*/ 5.0);
+    CPPUNIT_ASSERT_DOUBLES_EQUAL(636.0, aRect.at(15).getMinY(), /*delta*/ 5.0);
+
+    CPPUNIT_ASSERT_EQUAL(u"other"_ustr, aText.at(16).trim());
+}
+
 } // end anonymous namespace
 
 CPPUNIT_PLUGIN_IMPLEMENT();

Reply via email to