Files
odfstudio/sw/qa/core/text/EnhancedPDFExportHelper.cxx
T
Xisco Fauli 7003eb6fc6 fix headers
Change-Id: Ia2732cc041367bc113654bc424ea6621f3961e9f
Reviewed-on: https://gerrit.libreoffice.org/c/core/+/211724
Tested-by: Jenkins
Reviewed-by: Xisco Fauli <xiscofauli@libreoffice.org>
2026-10-05 08:52:04 +02:00

665 lines
29 KiB
C++

/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4; fill-column: 100 -*- */
/*
* 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/.
*/
#include <swmodeltestbase.hxx>
#include <algorithm>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <basegfx/range/b2drectangle.hxx>
#include <comphelper/propertyvalue.hxx>
#include <tools/stream.hxx>
#include <vcl/filter/PDFiumLibrary.hxx>
#include <vcl/filter/pdfdocument.hxx>
namespace
{
/// Covers sw/source/core/text/EnhancedPDFExportHelper.cxx fixes.
class Test : public SwModelTestBase
{
public:
Test()
: SwModelTestBase(u"/sw/qa/core/text/data/"_ustr)
{
}
};
// the text an element's own marked content holds, excluding any nested element's
OUString lcl_GetOwnText(vcl::pdf::PDFiumStructureElement& rElement,
const std::unordered_map<int, OUString>& rTexts)
{
OUString aText;
for (int i = 0; i < rElement.getNumberOfChildren(); ++i)
{
if (const int nMarkedContentID = rElement.getChildMarkedContentID(i); nMarkedContentID >= 0)
{
const auto it = rTexts.find(nMarkedContentID);
if (it != rTexts.end())
aText += it->second;
}
}
return aText.trim();
}
// every element holding text or a description, in document order, with the language it
// specifies - one specifying none is listed without a language, and inherits its parent's
OUString lcl_CollectLanguages(vcl::pdf::PDFiumStructureElement& rElement,
const std::unordered_map<int, OUString>& rTexts)
{
OUString aLanguages;
OUString aLabel(lcl_GetOwnText(rElement, rTexts));
if (aLabel.isEmpty())
{
// a frame holds no text of its own, so its description identifies it
aLabel = rElement.getAltText();
}
if (!aLabel.isEmpty())
{
const OUString aLanguage(rElement.getLang());
aLanguages = rElement.getType() + (aLanguage.isEmpty() ? u""_ustr : " " + aLanguage) + ": "
+ aLabel + "\n";
}
for (int i = 0; i < rElement.getNumberOfChildren(); ++i)
{
if (const auto pChild = rElement.getChild(i))
aLanguages += lcl_CollectLanguages(*pChild, rTexts);
}
return aLanguages;
}
// the kids of every Ruby, in the order they are emitted, each with the text it covers
OUString lcl_CollectRubies(vcl::pdf::PDFiumStructureElement& rElement,
const std::unordered_map<int, OUString>& rTexts)
{
OUString aRubies;
if (rElement.getType() == "Ruby")
{
for (int i = 0; i < rElement.getNumberOfChildren(); ++i)
{
if (const auto pKid = rElement.getChild(i))
aRubies += pKid->getType() + ": " + lcl_GetOwnText(*pKid, rTexts) + "\n";
}
}
for (int i = 0; i < rElement.getNumberOfChildren(); ++i)
{
if (const auto pChild = rElement.getChild(i))
aRubies += lcl_CollectRubies(*pChild, rTexts);
}
return aRubies;
}
CPPUNIT_TEST_FIXTURE(Test, testTdf171022)
{
createSwDoc("structure-destinations.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true),
comphelper::makePropertyValue(u"SelectPdfVersion"_ustr,
sal_Int32(20)) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
// the outline item, the footnote's two links, the shape's link and the citation's
std::vector<OString> aOutlineTypes;
std::vector<OString> aLinkTypes;
for (auto* pObject : aDocument.GetObjects())
{
auto pAction = dynamic_cast<vcl::filter::PDFDictionaryElement*>(pObject->Lookup("A"_ostr));
if (!pAction)
continue;
auto pType = dynamic_cast<vcl::filter::PDFNameElement*>(pAction->LookupElement("S"_ostr));
if (!pType || pType->GetValue() != "GoTo")
continue;
// ISO 14289-2 8.8: the destination inside the document is a structure destination, so
// it names the element and not only the page. veraPDF passes a structure destination
// that points at nothing, so check what it points at.
auto pPage = dynamic_cast<vcl::filter::PDFArrayElement*>(pAction->LookupElement("D"_ostr));
auto pStructure
= dynamic_cast<vcl::filter::PDFArrayElement*>(pAction->LookupElement("SD"_ostr));
CPPUNIT_ASSERT(pPage);
CPPUNIT_ASSERT(pStructure);
CPPUNIT_ASSERT(!pPage->GetElements().empty());
CPPUNIT_ASSERT(!pStructure->GetElements().empty());
auto pPageRef = dynamic_cast<vcl::filter::PDFReferenceElement*>(pPage->GetElements()[0]);
auto pStructureRef
= dynamic_cast<vcl::filter::PDFReferenceElement*>(pStructure->GetElements()[0]);
CPPUNIT_ASSERT(pPageRef);
CPPUNIT_ASSERT(pStructureRef);
CPPUNIT_ASSERT(pPageRef->LookupObject());
CPPUNIT_ASSERT(pStructureRef->LookupObject());
auto pPageType = dynamic_cast<vcl::filter::PDFNameElement*>(
pPageRef->LookupObject()->Lookup("Type"_ostr));
auto pStructureType = dynamic_cast<vcl::filter::PDFNameElement*>(
pStructureRef->LookupObject()->Lookup("Type"_ostr));
CPPUNIT_ASSERT(pPageType);
CPPUNIT_ASSERT(pStructureType);
CPPUNIT_ASSERT_EQUAL("Page"_ostr, pPageType->GetValue());
CPPUNIT_ASSERT_EQUAL("StructElem"_ostr, pStructureType->GetValue());
// which element it is, not merely that it is one: a jump that leaves the cursor behind
// names the paragraph the link sits in, and that reads as a plausible structure
// destination to a validator
auto pStructureS = dynamic_cast<vcl::filter::PDFNameElement*>(
pStructureRef->LookupObject()->Lookup("S"_ostr));
CPPUNIT_ASSERT(pStructureS);
// an outline item has a Title, a link annotation does not
if (pObject->Lookup("Title"_ostr))
aOutlineTypes.push_back(pStructureS->GetValue());
else
aLinkTypes.push_back(pStructureS->GetValue());
}
// the outline item names the heading it was made from
CPPUNIT_ASSERT_EQUAL(size_t(1), aOutlineTypes.size());
CPPUNIT_ASSERT_EQUAL("H1"_ostr, aOutlineTypes[0]);
// the shape's bookmark sits at the document's first position on purpose: the cursor is
// already there, and nothing moves
std::sort(aLinkTypes.begin(), aLinkTypes.end());
CPPUNIT_ASSERT_EQUAL(size_t(4), aLinkTypes.size());
CPPUNIT_ASSERT_EQUAL("Bibliography 1"_ostr, aLinkTypes[0]);
CPPUNIT_ASSERT_EQUAL("Footnote"_ostr, aLinkTypes[1]);
CPPUNIT_ASSERT_EQUAL("H1"_ostr, aLinkTypes[2]);
CPPUNIT_ASSERT_EQUAL("Standard"_ostr, aLinkTypes[3]);
}
CPPUNIT_TEST_FIXTURE(Test, testFootnoteNoteType)
{
createSwDoc("footnote-tag.fodt");
// the type of the one element a footnote frame opens
const auto aFootnoteType = [](vcl::filter::PDFDocument& rDocument) -> OString {
for (auto* pObject : rDocument.GetObjects())
{
auto pType = dynamic_cast<vcl::filter::PDFNameElement*>(pObject->Lookup("S"_ostr));
if (pType && (pType->GetValue() == "Note" || pType->GetValue() == "FENote"))
return pType->GetValue();
}
return OString();
};
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(
u"FilterData"_ustr,
uno::Sequence{
comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true),
comphelper::makePropertyValue(u"SelectPdfVersion"_ustr, sal_Int32(20)) }) });
vcl::filter::PDFDocument aPDF20;
CPPUNIT_ASSERT(aPDF20.Read(*maTempFile.GetStream(StreamMode::READ)));
// Without the fix this was Note, which ISO 14289-2 8.2.5.14 takes only where a role map
// sends it to the PDF 2.0 namespace
CPPUNIT_ASSERT_EQUAL("FENote"_ostr, aFootnoteType(aPDF20));
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(
u"FilterData"_ustr,
uno::Sequence{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) }) });
vcl::filter::PDFDocument aPDF17;
maTempFile.CloseStream();
CPPUNIT_ASSERT(aPDF17.Read(*maTempFile.GetStream(StreamMode::READ)));
// the only footnote type ISO 32000-1 has, FENote being a PDF 2.0 addition
CPPUNIT_ASSERT_EQUAL("Note"_ostr, aFootnoteType(aPDF17));
}
CPPUNIT_TEST_FIXTURE(Test, testTOCItemRef)
{
createSwDoc("toc-structure-ref.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true),
comphelper::makePropertyValue(u"SelectPdfVersion"_ustr,
sal_Int32(20)) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
// Without the fix a TOC item had no Ref at all, and ISO 14289-2 8.2.5.8 asks each of them to
// name the element its entry reaches
OStringBuffer aTargets;
std::unordered_set<sal_Int32> aSeen;
for (auto* pObject : aDocument.GetObjects())
{
auto pType = dynamic_cast<vcl::filter::PDFNameElement*>(pObject->Lookup("S"_ostr));
if (!pType || pType->GetValue() != "TOCI")
continue;
auto pRefs = dynamic_cast<vcl::filter::PDFArrayElement*>(pObject->Lookup("Ref"_ostr));
CPPUNIT_ASSERT(pRefs);
CPPUNIT_ASSERT_EQUAL(size_t(1), pRefs->GetElements().size());
auto pRef = dynamic_cast<vcl::filter::PDFReferenceElement*>(pRefs->GetElements()[0]);
CPPUNIT_ASSERT(pRef);
CPPUNIT_ASSERT(pRef->LookupObject());
// the heading of this entry, not the one the entry before it reached
CPPUNIT_ASSERT(aSeen.insert(pRef->GetObjectValue()).second);
auto pTargetType
= dynamic_cast<vcl::filter::PDFNameElement*>(pRef->LookupObject()->Lookup("S"_ostr));
CPPUNIT_ASSERT(pTargetType);
aTargets.append(pTargetType->GetValue() + " ");
}
// one item per heading, each naming its own
CPPUNIT_ASSERT_EQUAL("H1 H2 "_ostr, aTargets.makeStringAndClear());
}
CPPUNIT_TEST_FIXTURE(Test, testFormulaAltFromSource)
{
createSwDoc("formula-alt.fodt");
// a formula reaches the export loaded, not running, in every document but a freshly typed one
saveAndReload(TestFilter::ODT);
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
maTempFile.CloseStream();
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
OUStringBuffer aAlts;
for (auto* pObject : aDocument.GetObjects())
{
auto pType = dynamic_cast<vcl::filter::PDFNameElement*>(pObject->Lookup("S"_ostr));
if (!pType || pType->GetValue() != "Formula")
continue;
// Without the fix a formula nobody had described carried no Alt, which the PDF/UA
// checkers count against the file
auto pAlt = dynamic_cast<vcl::filter::PDFHexStringElement*>(pObject->Lookup("Alt"_ostr));
CPPUNIT_ASSERT(pAlt);
aAlts.append(vcl::filter::PDFDocument::DecodeHexStringUTF16BE(*pAlt) + "|");
}
// the source where nobody described the formula, and the title where somebody did
CPPUNIT_ASSERT_EQUAL(u"E=mc^2|Half of a|"_ustr, aAlts.makeStringAndClear());
}
CPPUNIT_TEST_FIXTURE(Test, testFormulaPlacement)
{
createSwDoc("formula-placement.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
OString aInALine;
OString aInAParagraph;
OString aOnThePage;
for (auto* pObject : aDocument.GetObjects())
{
auto pType = dynamic_cast<vcl::filter::PDFNameElement*>(pObject->Lookup("S"_ostr));
if (!pType || pType->GetValue() != "Formula")
continue;
auto pAttributes
= dynamic_cast<vcl::filter::PDFDictionaryElement*>(pObject->Lookup("A"_ostr));
CPPUNIT_ASSERT(pAttributes);
auto pPlacement = dynamic_cast<vcl::filter::PDFNameElement*>(
pAttributes->LookupElement("Placement"_ostr));
CPPUNIT_ASSERT(pPlacement);
// each formula names its own source, so the three are told apart by it
auto pAlt = dynamic_cast<vcl::filter::PDFHexStringElement*>(pObject->Lookup("Alt"_ostr));
CPPUNIT_ASSERT(pAlt);
const OUString aSource(vcl::filter::PDFDocument::DecodeHexStringUTF16BE(*pAlt));
if (aSource == u"E=mc^2")
aInALine = pPlacement->GetValue();
else if (aSource == u"a over 2")
aInAParagraph = pPlacement->GetValue();
else if (aSource == u"b+1")
aOnThePage = pPlacement->GetValue();
}
// Without the fix these were Block, which makes a block level element of a Formula that the
// export tags inside a paragraph, and PAC 2026 calls such a Formula inappropriate
CPPUNIT_ASSERT_EQUAL("Inline"_ostr, aInALine);
CPPUNIT_ASSERT_EQUAL("Inline"_ostr, aInAParagraph);
// one anchored to the page is tagged under the document, where it is a block of its own
CPPUNIT_ASSERT_EQUAL("Block"_ostr, aOnThePage);
}
CPPUNIT_TEST_FIXTURE(Test, testLayoutAttributeUnits)
{
createSwDoc("layout-attribute-units.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
maTempFile.CloseStream();
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
// the indented paragraph, the one whose first line hangs, and the first data cell; a
// header cell carries an array of two dictionaries instead, the layout one and the table one
vcl::filter::PDFDictionaryElement* pIndented = nullptr;
vcl::filter::PDFDictionaryElement* pHanging = nullptr;
vcl::filter::PDFDictionaryElement* pCell = nullptr;
for (auto* pObject : aDocument.GetObjects())
{
auto pType = dynamic_cast<vcl::filter::PDFNameElement*>(pObject->Lookup("S"_ostr));
auto pAttributes
= dynamic_cast<vcl::filter::PDFDictionaryElement*>(pObject->Lookup("A"_ostr));
if (!pType || !pAttributes)
continue;
// of the paragraphs only the indented one has a right margin; the table has one too,
// which is why the type is asked for as well
if (!pIndented && pType->GetValue() == "Standard"
&& pAttributes->LookupElement("EndIndent"_ostr))
pIndented = pAttributes;
else if (!pHanging && pType->GetValue() == "Standard"
&& pAttributes->LookupElement("TextIndent"_ostr))
pHanging = pAttributes;
else if (!pCell && pType->GetValue() == "TD")
pCell = pAttributes;
}
CPPUNIT_ASSERT(pIndented);
CPPUNIT_ASSERT(pHanging);
CPPUNIT_ASSERT(pCell);
const auto getLength = [](vcl::filter::PDFDictionaryElement* pAttributes, const OString& rKey) {
auto pNumber
= dynamic_cast<vcl::filter::PDFNumberElement*>(pAttributes->LookupElement(rKey));
CPPUNIT_ASSERT(pNumber);
return pNumber->GetValue();
};
// Without the fix these carried the twips the layout works in, where ISO 32000-2 14.8.5.4
// asks for default user space units - a fiftieth of the length they name.
// 0.5in, 0.4in and 0.25in of indent, and 0.2in above the paragraph
CPPUNIT_ASSERT_DOUBLES_EQUAL(36.0, getLength(pIndented, "StartIndent"_ostr), 0.01);
CPPUNIT_ASSERT_DOUBLES_EQUAL(28.8, getLength(pIndented, "EndIndent"_ostr), 0.01);
CPPUNIT_ASSERT_DOUBLES_EQUAL(18.0, getLength(pIndented, "TextIndent"_ostr), 0.01);
CPPUNIT_ASSERT_DOUBLES_EQUAL(14.4, getLength(pIndented, "SpaceBefore"_ostr), 0.01);
// a hanging first line, the one length that is negative
CPPUNIT_ASSERT_DOUBLES_EQUAL(-18.0, getLength(pHanging, "TextIndent"_ostr), 0.01);
// the 1.5in column the cell sits in
CPPUNIT_ASSERT_DOUBLES_EQUAL(108.0, getLength(pCell, "Width"_ostr), 0.01);
}
CPPUNIT_TEST_FIXTURE(Test, testParagraphLanguage)
{
createSwDoc("paragraph-language.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
// the value the entries below state where they state one, and what the rest inherit
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
auto pCatalog = aDocument.GetCatalog();
CPPUNIT_ASSERT(pCatalog);
CPPUNIT_ASSERT(pCatalog->GetDictionary());
auto pDocumentLanguage = dynamic_cast<vcl::filter::PDFLiteralStringElement*>(
pCatalog->GetDictionary()->LookupElement("Lang"_ostr));
CPPUNIT_ASSERT(pDocumentLanguage);
CPPUNIT_ASSERT_EQUAL("en-US"_ostr, pDocumentLanguage->GetValue());
auto pPdfDocument = parsePDFExport(vcl::pdf::PDFiumLibrary::get());
auto pPdfPage = pPdfDocument->openPage(0);
CPPUNIT_ASSERT(pPdfPage);
auto pTextPage = pPdfPage->getTextPage();
// the text behind each marked content id, to identify the element covering it
std::unordered_map<int, OUString> aTexts;
for (int i = 0; i < pPdfPage->getObjectCount(); ++i)
{
auto pObject = pPdfPage->getObject(i);
if (const int nMarkedContentID = pObject->getMarkedContentID(); nMarkedContentID >= 0)
aTexts[nMarkedContentID] += pObject->getText(pTextPage);
}
auto pTree = pPdfPage->getStructureTree();
CPPUNIT_ASSERT(pTree);
OUString aLanguages;
for (int i = 0; i < pTree->getNumberOfChildren(); ++i)
{
if (const auto pChild = pTree->getChild(i))
aLanguages += lcl_CollectLanguages(*pChild, aTexts);
}
CPPUNIT_ASSERT_EQUAL(
// specifies no language, being in the document's, so it inherits
u"P: A paragraph in the language of the document.\n"
"P de-DE: Ein Absatz, der ganz und gar deutsch ist.\n"
// the gap is where the German span took its words out of the paragraph
"P: English with in it.\n"
"Span de-DE: ein paar deutsche Worte\n"
"P de-DE: Ein deutscher Absatz mit darin.\n"
// the run differs from its paragraph, not from the document
"Span en-US: a few English words\n"
// a paragraph holding an anchor specified no language before, a Span carrying it
"P de-DE: Ein deutscher Absatz mit einem Rahmen.\n"
// the shape's own element, so its description does not inherit the paragraph's
"Div en-US: A shape described in the language of the document\n"
// the shape's text, tagged by drawinglayer since fr1 has no parent style
"P en-US: Text inside the frame.\n"
"P de-DE: Ein deutscher Absatz mit einem Textrahmen.\n"
// a Writer text frame, identified by its description
"Div en-US: Described in the language of the document\n"
// specifies none: the frame's Div specifies en-US for what it contains
"P: Text in a Writer frame.\n"
"P de-DE: Ein deutscher Absatz mit einem Bild.\n"
"Figure en-US: A picture described in the language of the document\n"_ustr,
aLanguages);
}
// the rectangle of every link annotation, down the page, each annotation carrying the
// /StructParent that ISO 14289-1 7.18.5 needs to find its Link element
std::vector<basegfx::B2DRectangle> lcl_CollectTaggedLinkRects(vcl::filter::PDFDocument& rDocument)
{
std::vector<basegfx::B2DRectangle> aRects;
for (auto* pObject : rDocument.GetObjects())
{
auto pSubtype = dynamic_cast<vcl::filter::PDFNameElement*>(pObject->Lookup("Subtype"_ostr));
if (!pSubtype || pSubtype->GetValue() != "Link")
continue;
CPPUNIT_ASSERT_MESSAGE("a link annotation with no /StructParent",
pObject->Lookup("StructParent"_ostr));
auto pRect = dynamic_cast<vcl::filter::PDFArrayElement*>(pObject->Lookup("Rect"_ostr));
CPPUNIT_ASSERT(pRect);
CPPUNIT_ASSERT_EQUAL(size_t(4), pRect->GetElements().size());
const auto aCorner = [pRect](size_t nIndex) {
auto pNumber = dynamic_cast<vcl::filter::PDFNumberElement*>(pRect->GetElement(nIndex));
CPPUNIT_ASSERT(pNumber);
return pNumber->GetValue();
};
aRects.emplace_back(aCorner(0), aCorner(1), aCorner(2), aCorner(3));
}
// PDF y coordinates increase upwards, so the highest bottom is the first line
std::ranges::sort(aRects,
[](const basegfx::B2DRectangle& rLeft, const basegfx::B2DRectangle& rRight) {
return rLeft.getMinY() > rRight.getMinY();
});
return aRects;
}
CPPUNIT_TEST_FIXTURE(Test, testLinkEndsInRuby)
{
createSwDoc("link-ends-in-ruby.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
// one rectangle per line the link covers, and no third one over the empty band beside
// the ruby, which no structure element could cover
const std::vector<basegfx::B2DRectangle> aRects(lcl_CollectTaggedLinkRects(aDocument));
CPPUNIT_ASSERT_EQUAL(size_t(2), aRects.size());
// the first line holds text alone; the second carries the ruby, and its rectangle
// stands over the ruby's band so that the phonetic annotation takes the click
CPPUNIT_ASSERT_DOUBLES_EQUAL(13.8, aRects[0].getHeight(), 0.5);
CPPUNIT_ASSERT_DOUBLES_EQUAL(20.7, aRects[1].getHeight(), 0.5);
}
CPPUNIT_TEST_FIXTURE(Test, testLinkEndsInRubyVertical)
{
createSwDoc("link-ends-in-ruby-vertical.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
// the region split hands this line back in pieces, one of them holding the ruby and
// none of the line's text, which nothing paints in and so nothing tags; the count is
// what this pins - the width was already right before the pieces were joined
const std::vector<basegfx::B2DRectangle> aRects(lcl_CollectTaggedLinkRects(aDocument));
CPPUNIT_ASSERT_EQUAL(size_t(1), aRects.size());
CPPUNIT_ASSERT_DOUBLES_EQUAL(20.7, aRects[0].getWidth(), 0.5);
}
CPPUNIT_TEST_FIXTURE(Test, testLinkEndsInRubyVerticalWrapped)
{
createSwDoc("link-ends-in-ruby-vertical-wrapped.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
// an annotation for each of the three lines, all tagged, which the collector asserts
std::vector<basegfx::B2DRectangle> aRects(lcl_CollectTaggedLinkRects(aDocument));
CPPUNIT_ASSERT_EQUAL(size_t(3), aRects.size());
// the text runs down the page from the right, so the widest x is the first line
std::ranges::sort(aRects,
[](const basegfx::B2DRectangle& rLeft, const basegfx::B2DRectangle& rRight) {
return rLeft.getMinX() > rRight.getMinX();
});
// only the third line carries the ruby, and only it is as wide as a ruby makes a line
CPPUNIT_ASSERT_DOUBLES_EQUAL(13.8, aRects[0].getWidth(), 0.5);
CPPUNIT_ASSERT_DOUBLES_EQUAL(13.8, aRects[1].getWidth(), 0.5);
CPPUNIT_ASSERT_DOUBLES_EQUAL(20.7, aRects[2].getWidth(), 0.5);
}
CPPUNIT_TEST_FIXTURE(Test, testLinkPartRubyOneLine)
{
createSwDoc("link-part-ruby-one-line.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
// an annotation is a plain box, so a ruby over the link's second half raises the whole
// rectangle; leaving the first half its own height would need a quadrilateral
const std::vector<basegfx::B2DRectangle> aRects(lcl_CollectTaggedLinkRects(aDocument));
CPPUNIT_ASSERT_EQUAL(size_t(1), aRects.size());
CPPUNIT_ASSERT_DOUBLES_EQUAL(20.7, aRects[0].getHeight(), 0.5);
}
CPPUNIT_TEST_FIXTURE(Test, testLinkStartsAfterRuby)
{
createSwDoc("link-starts-after-ruby.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
const std::vector<basegfx::B2DRectangle> aRects(lcl_CollectTaggedLinkRects(aDocument));
CPPUNIT_ASSERT_EQUAL(size_t(1), aRects.size());
// the ruby raises the line, so the link standing beside it is as tall as the line
CPPUNIT_ASSERT_DOUBLES_EQUAL(20.7, aRects[0].getHeight(), 0.5);
// the ruby belongs to no link, so the rectangle starts to the right of it, not at the
// left text margin of 56.7pt
CPPUNIT_ASSERT_GREATER(65.0, aRects[0].getMinX());
}
CPPUNIT_TEST_FIXTURE(Test, testLinkStartsAtAbuttingRuby)
{
createSwDoc("link-starts-at-abutting-ruby.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
vcl::filter::PDFDocument aDocument;
CPPUNIT_ASSERT(aDocument.Read(*maTempFile.GetStream(StreamMode::READ)));
const std::vector<basegfx::B2DRectangle> aRects(lcl_CollectTaggedLinkRects(aDocument));
CPPUNIT_ASSERT_EQUAL(size_t(1), aRects.size());
CPPUNIT_ASSERT_DOUBLES_EQUAL(20.7, aRects[0].getHeight(), 0.5);
// a hit test at the link's first character lands in the ruby before it, which the link
// does not cover, so the rectangle starts at the second ruby
CPPUNIT_ASSERT_GREATER(65.0, aRects[0].getMinX());
}
CPPUNIT_TEST_FIXTURE(Test, testRubyStructureOrder)
{
createSwDoc("ruby-positions.fodt");
uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true) };
save(TestFilter::PDF_WRITER,
{ comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
auto pPdfDocument = parsePDFExport(vcl::pdf::PDFiumLibrary::get());
auto pPdfPage = pPdfDocument->openPage(0);
CPPUNIT_ASSERT(pPdfPage);
auto pTextPage = pPdfPage->getTextPage();
// the text behind each marked content id, to identify the element covering it
std::unordered_map<int, OUString> aTexts;
for (int i = 0; i < pPdfPage->getObjectCount(); ++i)
{
auto pObject = pPdfPage->getObject(i);
if (const int nMarkedContentID = pObject->getMarkedContentID(); nMarkedContentID >= 0)
aTexts[nMarkedContentID] += pObject->getText(pTextPage);
}
auto pTree = pPdfPage->getStructureTree();
CPPUNIT_ASSERT(pTree);
OUString aRubies;
for (int i = 0; i < pTree->getNumberOfChildren(); ++i)
{
if (const auto pChild = pTree->getChild(i))
aRubies += lcl_CollectRubies(*pChild, aTexts);
}
// ISO 32000-2 Table 369 requires RB first. An annotation above its base paints first and
// is still the RT; one below paints second and is still the RT
CPPUNIT_ASSERT_EQUAL(u"RB: base above\n"
"RT: anno above\n"
"RB: base below\n"
"RT: anno below\n"_ustr,
aRubies);
}
}
/* vim:set shiftwidth=4 softtabstop=4 expandtab: */