summaryrefslogtreecommitdiffstats
path: root/writerperfect/qa/unit/EPUBExportTest.cxx
diff options
context:
space:
mode:
Diffstat (limited to 'writerperfect/qa/unit/EPUBExportTest.cxx')
-rw-r--r--writerperfect/qa/unit/EPUBExportTest.cxx886
1 files changed, 886 insertions, 0 deletions
diff --git a/writerperfect/qa/unit/EPUBExportTest.cxx b/writerperfect/qa/unit/EPUBExportTest.cxx
new file mode 100644
index 000000000..71bf41130
--- /dev/null
+++ b/writerperfect/qa/unit/EPUBExportTest.cxx
@@ -0,0 +1,886 @@
+/* -*- 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/.
+ */
+
+#include <sal/config.h>
+
+#include <string_view>
+
+#include <libepubgen/libepubgen.h>
+
+#include <com/sun/star/document/XFilter.hpp>
+#include <com/sun/star/frame/Desktop.hpp>
+#include <com/sun/star/frame/XStorable.hpp>
+#include <com/sun/star/lang/XServiceInfo.hpp>
+#include <com/sun/star/packages/zip/ZipFileAccess.hpp>
+
+#include <comphelper/propertysequence.hxx>
+#include <comphelper/string.hxx>
+#include <o3tl/safeint.hxx>
+#include <test/bootstrapfixture.hxx>
+#include <test/xmltesttools.hxx>
+#include <unotest/macros_test.hxx>
+#include <unotools/docinfohelper.hxx>
+#include <unotools/mediadescriptor.hxx>
+#include <unotools/tempfile.hxx>
+#include <unotools/ucbstreamhelper.hxx>
+#include <o3tl/string_view.hxx>
+
+using namespace ::com::sun::star;
+
+namespace
+{
+constexpr OUStringLiteral DATA_DIRECTORY = u"/writerperfect/qa/unit/data/writer/epubexport/";
+
+/// Tests the EPUB export filter.
+class EPUBExportTest : public test::BootstrapFixture,
+ public unotest::MacrosTest,
+ public XmlTestTools
+{
+protected:
+ uno::Reference<lang::XComponent> mxComponent;
+ utl::TempFile maTempFile;
+ xmlDocUniquePtr mpXmlDoc;
+ uno::Reference<packages::zip::XZipFileAccess2> mxZipFile;
+ OUString maFilterOptions;
+
+public:
+ void setUp() override;
+ void tearDown() override;
+ void registerNamespaces(xmlXPathContextPtr& pXmlXpathCtx) override;
+ void createDoc(std::u16string_view rFile,
+ const uno::Sequence<beans::PropertyValue>& rFilterData);
+ /// Returns an XML representation of the stream named rName in the exported package.
+ xmlDocUniquePtr parseExport(const OUString& rName);
+ /// Parses a CSS representation of the stream named rName and returns it.
+ std::map<OUString, std::vector<OUString>> parseCss(const OUString& rName);
+ /// Looks up a key of a class in rCss.
+ static OUString getCss(std::map<OUString, std::vector<OUString>>& rCss, const OUString& rClass,
+ std::u16string_view rKey);
+};
+
+void EPUBExportTest::setUp()
+{
+ test::BootstrapFixture::setUp();
+
+ mxDesktop.set(frame::Desktop::create(mxComponentContext));
+}
+
+void EPUBExportTest::tearDown()
+{
+ if (mxComponent.is())
+ mxComponent->dispose();
+
+ mpXmlDoc.reset();
+
+ test::BootstrapFixture::tearDown();
+}
+
+void EPUBExportTest::registerNamespaces(xmlXPathContextPtr& pXmlXpathCtx)
+{
+ xmlXPathRegisterNs(pXmlXpathCtx, BAD_CAST("dc"), BAD_CAST("http://purl.org/dc/elements/1.1/"));
+ xmlXPathRegisterNs(pXmlXpathCtx, BAD_CAST("opf"), BAD_CAST("http://www.idpf.org/2007/opf"));
+ xmlXPathRegisterNs(pXmlXpathCtx, BAD_CAST("xhtml"), BAD_CAST("http://www.w3.org/1999/xhtml"));
+ xmlXPathRegisterNs(pXmlXpathCtx, BAD_CAST("svg"), BAD_CAST("http://www.w3.org/2000/svg"));
+}
+
+void EPUBExportTest::createDoc(std::u16string_view rFile,
+ const uno::Sequence<beans::PropertyValue>& rFilterData)
+{
+ // Import the bugdoc and export as EPUB.
+ OUString aURL = m_directories.getURLFromSrc(DATA_DIRECTORY) + rFile;
+ mxComponent = loadFromDesktop(aURL);
+ uno::Reference<frame::XStorable> xStorable(mxComponent, uno::UNO_QUERY);
+ maTempFile.EnableKillingFile();
+ utl::MediaDescriptor aMediaDescriptor;
+ aMediaDescriptor["FilterName"] <<= OUString("EPUB");
+ if (maFilterOptions.isEmpty())
+ aMediaDescriptor["FilterData"] <<= rFilterData;
+ else
+ aMediaDescriptor["FilterOptions"] <<= maFilterOptions;
+ xStorable->storeToURL(maTempFile.GetURL(), aMediaDescriptor.getAsConstPropertyValueList());
+ mxZipFile
+ = packages::zip::ZipFileAccess::createWithURL(mxComponentContext, maTempFile.GetURL());
+}
+
+xmlDocUniquePtr EPUBExportTest::parseExport(const OUString& rName)
+{
+ uno::Reference<io::XInputStream> xInputStream(mxZipFile->getByName(rName), uno::UNO_QUERY);
+ std::unique_ptr<SvStream> pStream(utl::UcbStreamHelper::CreateStream(xInputStream, true));
+ return parseXmlStream(pStream.get());
+}
+
+std::map<OUString, std::vector<OUString>> EPUBExportTest::parseCss(const OUString& rName)
+{
+ std::map<OUString, std::vector<OUString>> aRet;
+
+ uno::Reference<io::XInputStream> xInputStream(mxZipFile->getByName(rName), uno::UNO_QUERY);
+ std::unique_ptr<SvStream> pStream(utl::UcbStreamHelper::CreateStream(xInputStream, true));
+
+ // Minimal CSS handler till orcus is up to our needs.
+ OString aLine;
+ OUString aRuleName;
+ while (!pStream->eof())
+ {
+ pStream->ReadLine(aLine);
+ if (aLine.endsWith("{"))
+ // '.name {' -> 'name'
+ aRuleName = OUString::fromUtf8(aLine.subView(1, aLine.getLength() - 3));
+ else if (aLine.endsWith(";"))
+ aRet[aRuleName].push_back(OUString::fromUtf8(aLine));
+ }
+
+ return aRet;
+}
+
+OUString EPUBExportTest::getCss(std::map<OUString, std::vector<OUString>>& rCss,
+ const OUString& rClass, std::u16string_view rKey)
+{
+ OUString aRet;
+
+ auto it = rCss.find(rClass);
+ CPPUNIT_ASSERT(it != rCss.end());
+
+ for (const auto& rKeyValue : it->second)
+ {
+ OUString aKeyValue = rKeyValue.trim();
+ std::vector<OUString> aTokens = comphelper::string::split(aKeyValue, ':');
+ CPPUNIT_ASSERT_EQUAL(static_cast<size_t>(2), aTokens.size());
+ if (o3tl::trim(aTokens[0]) == rKey)
+ {
+ aRet = aTokens[1].trim();
+ if (aRet.endsWith(";"))
+ // Ignore trailing semicolon.
+ aRet = aRet.copy(0, aRet.getLength() - 1);
+ break;
+ }
+ }
+
+ return aRet;
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testOutlineLevel)
+{
+ createDoc(u"outline-level.fodt", {});
+
+ // Make sure that the output is split into two.
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/sections/section0001.xhtml"));
+ // This failed, output was a single section.
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/sections/section0002.xhtml"));
+ CPPUNIT_ASSERT(!mxZipFile->hasByName("OEBPS/sections/section0003.xhtml"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testMimetype)
+{
+ createDoc(u"hello.fodt", {});
+
+ // Check that the mime type is written uncompressed at the expected location.
+ SvFileStream aFileStream(maTempFile.GetURL(), StreamMode::READ);
+ SvMemoryStream aMemoryStream;
+ aMemoryStream.WriteStream(aFileStream);
+ OString aExpected("application/epub+zip");
+ CPPUNIT_ASSERT(aMemoryStream.GetSize() > static_cast<sal_uInt64>(aExpected.getLength()) + 38);
+
+ OString aActual(static_cast<const char*>(aMemoryStream.GetData()) + 38, aExpected.getLength());
+ // This failed: actual data was some garbage, not the uncompressed mime type.
+ CPPUNIT_ASSERT_EQUAL(aExpected, aActual);
+
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+ // Default is EPUB3.
+ assertXPath(mpXmlDoc, "/opf:package", "version", "3.0");
+
+ // This was just "libepubgen/x.y.z", i.e. the LO version was missing.
+ OUString aGenerator
+ = getXPath(mpXmlDoc, "/opf:package/opf:metadata/opf:meta[@name='generator']", "content");
+ CPPUNIT_ASSERT(aGenerator.startsWith(utl::DocInfoHelper::GetGeneratorString()));
+
+ uno::Reference<lang::XMultiServiceFactory> xMSF(mxComponentContext->getServiceManager(),
+ uno::UNO_QUERY);
+ const OUString aServiceName("com.sun.star.comp.Writer.EPUBExportFilter");
+ uno::Reference<document::XFilter> xFilter(xMSF->createInstance(aServiceName), uno::UNO_QUERY);
+ // Should result in no errors.
+ xFilter->cancel();
+ // We got back what we expected.
+ uno::Reference<lang::XServiceInfo> xServiceInfo(xFilter, uno::UNO_QUERY);
+ CPPUNIT_ASSERT_EQUAL(aServiceName, xServiceInfo->getImplementationName());
+ CPPUNIT_ASSERT(xServiceInfo->supportsService("com.sun.star.document.ExportFilter"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testEPUB2)
+{
+ uno::Sequence<beans::PropertyValue> aFilterData(comphelper::InitPropertySequence(
+ { // Explicitly request EPUB2.
+ { "EPUBVersion", uno::Any(static_cast<sal_Int32>(20)) } }));
+ createDoc(u"hello.fodt", aFilterData);
+
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+ // This was 3.0, EPUBVersion filter option was ignored and we always emitted EPUB3.
+ assertXPath(mpXmlDoc, "/opf:package", "version", "2.0");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testEPUBFixedLayout)
+{
+ uno::Sequence<beans::PropertyValue> aFilterData(comphelper::InitPropertySequence(
+ { // Explicitly request fixed layout.
+ { "EPUBLayoutMethod",
+ uno::Any(static_cast<sal_Int32>(libepubgen::EPUB_LAYOUT_METHOD_FIXED)) } }));
+ createDoc(u"hello.fodt", aFilterData);
+
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+ // This was missing, EPUBLayoutMethod filter option was ignored and we always emitted reflowable layout.
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/opf:meta[@property='rendition:layout']",
+ "pre-paginated");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testEPUBFixedLayoutOption)
+{
+ // Explicitly request fixed layout, this time via FilterOptions.
+ maFilterOptions = "layout=fixed";
+ createDoc(u"hello.fodt", {});
+
+ // This failed, fixed layout was only working via the FilterData map.
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/opf:meta[@property='rendition:layout']",
+ "pre-paginated");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testEPUBFixedLayoutImplicitBreak)
+{
+ uno::Sequence<beans::PropertyValue> aFilterData(comphelper::InitPropertySequence(
+ { // Explicitly request fixed layout.
+ { "EPUBLayoutMethod",
+ uno::Any(static_cast<sal_Int32>(libepubgen::EPUB_LAYOUT_METHOD_FIXED)) } }));
+ createDoc(u"fxl-2page.fodt", aFilterData);
+
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/sections/section0001.xhtml"));
+ // This was missing, implicit page break (as calculated by the layout) was lost on export.
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/sections/section0002.xhtml"));
+ CPPUNIT_ASSERT(!mxZipFile->hasByName("OEBPS/sections/section0003.xhtml"));
+
+ // Make sure that fixed layout has chapter names in the navigation
+ // document.
+ mpXmlDoc = parseExport("OEBPS/toc.xhtml");
+ // This was 'Page 1' instead.
+ assertXPathContent(mpXmlDoc, "//xhtml:li[1]/xhtml:a", "First chapter");
+ assertXPathContent(mpXmlDoc, "//xhtml:li[2]/xhtml:a", "Second chapter");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testPageBreakSplit)
+{
+ uno::Sequence<beans::PropertyValue> aFilterData(comphelper::InitPropertySequence(
+ { // Explicitly request split on page break (instead of on heading).
+ { "EPUBSplitMethod",
+ uno::Any(static_cast<sal_Int32>(libepubgen::EPUB_SPLIT_METHOD_PAGE_BREAK)) } }));
+ createDoc(u"2pages.fodt", aFilterData);
+
+ // Make sure that the output is split into two.
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/sections/section0001.xhtml"));
+ // This failed, output was a single section.
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/sections/section0002.xhtml"));
+ CPPUNIT_ASSERT(!mxZipFile->hasByName("OEBPS/sections/section0003.xhtml"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testSpanAutostyle)
+{
+ createDoc(u"span-autostyle.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:span[1]", "class", "span0");
+ // This failed, it was still span1, i.e. the bold and the italic formatting
+ // did not differ.
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:span[2]", "class", "span1");
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:span[3]", "class", "span2");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testParaAutostyleCharProps)
+{
+ createDoc(u"para-autostyle-char-props.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // This failed, para-level char props were not exported.
+ assertXPath(mpXmlDoc, "//xhtml:p[1]/xhtml:span", "class", "span0");
+ assertXPath(mpXmlDoc, "//xhtml:p[2]/xhtml:span", "class", "span1");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testMeta)
+{
+ createDoc(u"meta.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+ // This was "Unknown Author", <meta:initial-creator> was not handled.
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:creator", "A U Thor");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:title", "Title");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:language", "hu");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/opf:meta[@property='dcterms:modified']",
+ "2017-09-27T09:51:19Z");
+
+ // Make sure that cover image next to the source document is picked up.
+ assertXPath(mpXmlDoc, "/opf:package/opf:manifest/opf:item[@href='images/image0001.png']",
+ "properties", "cover-image");
+ assertXPath(mpXmlDoc, "/opf:package/opf:manifest/opf:item[@href='images/image0001.png']",
+ "media-type", "image/png");
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/images/image0001.png"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testMetaXMP)
+{
+ createDoc(u"meta-xmp.fodt", {});
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+
+ // These were the libepubgen default values, metadata from a matching .xmp file was not picked up.
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:identifier",
+ "deadbeef-e394-4cd6-9b83-7172794612e5");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:title", "unknown title from xmp");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:creator", "unknown author from xmp");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:language", "nl");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/opf:meta[@property='dcterms:modified']",
+ "2016-11-20T17:16:07Z");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testMetaAPI)
+{
+ uno::Sequence<beans::PropertyValue> aFilterData(comphelper::InitPropertySequence(
+ { { "RVNGIdentifier", uno::Any(OUString("deadc0de-e394-4cd6-9b83-7172794612e5")) },
+ { "RVNGTitle", uno::Any(OUString("unknown title from api")) },
+ { "RVNGInitialCreator", uno::Any(OUString("unknown author from api")) },
+ { "RVNGLanguage", uno::Any(OUString("hu")) },
+ { "RVNGDate", uno::Any(OUString("2015-11-20T17:16:07Z")) } }));
+ createDoc(u"meta-xmp.fodt", aFilterData);
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+
+ // These were values from XMP (deadbeef, etc.), not from API.
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:identifier",
+ "deadc0de-e394-4cd6-9b83-7172794612e5");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:title", "unknown title from api");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:creator", "unknown author from api");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/dc:language", "hu");
+ assertXPathContent(mpXmlDoc, "/opf:package/opf:metadata/opf:meta[@property='dcterms:modified']",
+ "2015-11-20T17:16:07Z");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testCoverImage)
+{
+ OUString aCoverURL = m_directories.getURLFromSrc(DATA_DIRECTORY) + "meta.cover-image.png";
+ uno::Sequence<beans::PropertyValue> aFilterData(
+ comphelper::InitPropertySequence({ { "RVNGCoverImage", uno::Any(aCoverURL) } }));
+ createDoc(u"hello.fodt", aFilterData);
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+
+ // Make sure that the explicitly set cover image is used.
+ // This failed, as the image was not part of the package.
+ assertXPath(mpXmlDoc, "/opf:package/opf:manifest/opf:item[@href='images/image0001.png']",
+ "properties", "cover-image");
+ assertXPath(mpXmlDoc, "/opf:package/opf:manifest/opf:item[@href='images/image0001.png']",
+ "media-type", "image/png");
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/images/image0001.png"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testParaNamedstyle)
+{
+ createDoc(u"para-namedstyle.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ assertXPath(mpXmlDoc, "//xhtml:p[1]", "class", "para0");
+ // This failed, paragraph properties from style were not exported.
+ assertXPath(mpXmlDoc, "//xhtml:p[2]", "class", "para1");
+
+ // Test character properties from named paragraph style.
+ assertXPath(mpXmlDoc, "//xhtml:p[1]/xhtml:span", "class", "span0");
+ // This failed, character properties from paragraph style were not exported.
+ assertXPath(mpXmlDoc, "//xhtml:p[2]/xhtml:span", "class", "span1");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testCharNamedstyle)
+{
+ createDoc(u"char-namedstyle.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+
+ // Test character properties from named text style.
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:span[1]", "class", "span0");
+ // This failed, character properties from text style were not exported.
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:span[2]", "class", "span1");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testNamedStyleInheritance)
+{
+ createDoc(u"named-style-inheritance.fodt", {});
+
+ // Find the CSS rule for the blue text.
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ OUString aBlue = getXPath(mpXmlDoc, "//xhtml:p[2]/xhtml:span[2]", "class");
+
+ CPPUNIT_ASSERT_EQUAL(OUString("#0000ff"), EPUBExportTest::getCss(aCssDoc, aBlue, u"color"));
+ // This failed, the span only had the properties from its style, but not
+ // from the style's parent(s).
+ CPPUNIT_ASSERT_EQUAL(OUString("'Liberation Mono'"),
+ EPUBExportTest::getCss(aCssDoc, aBlue, u"font-family"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testNestedSpan)
+{
+ createDoc(u"nested-span.fodt", {});
+
+ // Check textural content of nested span.
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ // This crashed, span had no content.
+ assertXPathContent(mpXmlDoc, "//xhtml:p/xhtml:span[2]", "red");
+
+ // Check formatting of nested span.
+ OUString aRed = getXPath(mpXmlDoc, "//xhtml:p/xhtml:span[2]", "class");
+ // This failed, direct formatting on top of named style was lost.
+ CPPUNIT_ASSERT_EQUAL(OUString("#ff0000"), EPUBExportTest::getCss(aCssDoc, aRed, u"color"));
+ CPPUNIT_ASSERT_EQUAL(OUString("'Liberation Mono'"),
+ EPUBExportTest::getCss(aCssDoc, aRed, u"font-family"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testLineBreak)
+{
+ createDoc(u"line-break.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // This was 0, line break was not handled.
+ assertXPath(mpXmlDoc, "//xhtml:p[1]/xhtml:span/xhtml:br", 1);
+ // This was 0, line break inside span was not handled.
+ assertXPath(mpXmlDoc, "//xhtml:p[2]/xhtml:span/xhtml:br", 1);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testEscape)
+{
+ createDoc(u"escape.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // This was lost.
+ assertXPathContent(mpXmlDoc, "//xhtml:p[1]/xhtml:span[1]", OUString::fromUtf8("\xc2\xa0"));
+ // Make sure escaping happens only once.
+ assertXPathContent(mpXmlDoc, "//xhtml:p[1]/xhtml:span[2]", "a&b");
+ // This was also lost.
+ assertXPathContent(
+ mpXmlDoc, "//xhtml:p[1]/xhtml:span[3]",
+ OUString::fromUtf8("\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2"
+ "\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0 "));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testParaCharProps)
+{
+ createDoc(u"para-char-props.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ // Check formatting of the middle span.
+ OUString aMiddle = getXPath(mpXmlDoc, "//xhtml:p/xhtml:span[2]", "class");
+ CPPUNIT_ASSERT_EQUAL(OUString("italic"),
+ EPUBExportTest::getCss(aCssDoc, aMiddle, u"font-style"));
+ // Direct para formatting was lost, only direct char formatting was
+ // written, so this failed.
+ CPPUNIT_ASSERT_EQUAL(OUString("bold"),
+ EPUBExportTest::getCss(aCssDoc, aMiddle, u"font-weight"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testSection)
+{
+ createDoc(u"section.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // This was "After.", i.e. in-section content was ignored.
+ assertXPathContent(mpXmlDoc, "//xhtml:p[2]/xhtml:span", "In section.");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testList)
+{
+ createDoc(u"list.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // This was "C", i.e. in-list content was ignored.
+ assertXPathContent(mpXmlDoc, "//xhtml:p[2]/xhtml:span", "B");
+ // Test nested list content.
+ assertXPathContent(mpXmlDoc, "//xhtml:p[6]/xhtml:span", "F");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testImage)
+{
+ createDoc(u"image.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:img", 1);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testImageBorder)
+{
+ createDoc(u"image-border.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:img", "class");
+ // This failed, image had no border.
+ CPPUNIT_ASSERT_EQUAL(OUString("0.99pt dashed #ed1c24"),
+ EPUBExportTest::getCss(aCssDoc, aClass, u"border"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testImageNospan)
+{
+ createDoc(u"image-nospan.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // Image outside a span was lost.
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:img", 1);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTable)
+{
+ createDoc(u"table.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ assertXPath(mpXmlDoc, "//xhtml:table/xhtml:tbody/xhtml:tr/xhtml:td", 4);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTableRowSpan)
+{
+ createDoc(u"table-row-span.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // This failed, row span wasn't exported.
+ assertXPath(mpXmlDoc, "//xhtml:table/xhtml:tbody/xhtml:tr[1]/xhtml:td[1]", "rowspan", "2");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTableCellBorder)
+{
+ createDoc(u"table-cell-border.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+
+ OUString aClass
+ = getXPath(mpXmlDoc, "//xhtml:table/xhtml:tbody/xhtml:tr[1]/xhtml:td[1]", "class");
+ // This failed, cell border wasn't exported.
+ CPPUNIT_ASSERT_EQUAL(OUString("0.05pt solid #000000"),
+ EPUBExportTest::getCss(aCssDoc, aClass, u"border-left"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTableCellWidth)
+{
+ createDoc(u"table-cell-width.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ OUString aClass1
+ = getXPath(mpXmlDoc, "//xhtml:table/xhtml:tbody/xhtml:tr[1]/xhtml:td[1]", "class");
+ OUString aClass2
+ = getXPath(mpXmlDoc, "//xhtml:table/xhtml:tbody/xhtml:tr[1]/xhtml:td[2]", "class");
+ OUString aClass3
+ = getXPath(mpXmlDoc, "//xhtml:table/xhtml:tbody/xhtml:tr[1]/xhtml:td[3]", "class");
+ // These failed, all widths were 0.
+ CPPUNIT_ASSERT_GREATER(EPUBExportTest::getCss(aCssDoc, aClass2, u"width").toDouble(),
+ EPUBExportTest::getCss(aCssDoc, aClass1, u"width").toDouble());
+ CPPUNIT_ASSERT_GREATER(EPUBExportTest::getCss(aCssDoc, aClass3, u"width").toDouble(),
+ EPUBExportTest::getCss(aCssDoc, aClass1, u"width").toDouble());
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTableRowHeight)
+{
+ createDoc(u"table-row-height.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ OUString aClass1 = getXPath(mpXmlDoc, "//xhtml:table/xhtml:tbody/xhtml:tr[1]", "class");
+ OUString aClass2 = getXPath(mpXmlDoc, "//xhtml:table/xhtml:tbody/xhtml:tr[2]", "class");
+ // These failed, both heights were 0.
+ CPPUNIT_ASSERT_GREATER(EPUBExportTest::getCss(aCssDoc, aClass2, u"height").toDouble(),
+ EPUBExportTest::getCss(aCssDoc, aClass1, u"height").toDouble());
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testLink)
+{
+ createDoc(u"link.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ assertXPathContent(mpXmlDoc, "//xhtml:p/xhtml:a/xhtml:span", "https://libreoffice.org/");
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:a", "href", "https://libreoffice.org/");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testLinkInvalid)
+{
+ createDoc(u"link-invalid.odt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // This was 1, invalid relative link was not filtered out.
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:a", 0);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testLinkCharFormat)
+{
+ createDoc(u"link-charformat.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // <span> was lost, link text having a char format was missing.
+ assertXPathContent(mpXmlDoc, "//xhtml:p/xhtml:a/xhtml:span", "https://libreoffice.org/");
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:a", "href", "https://libreoffice.org/");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testLinkNamedCharFormat)
+{
+ // Character properties from named character style on hyperlink was lost.
+ createDoc(u"link-namedcharformat.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ // This failed, there was no span inside the hyperlink.
+ assertXPathContent(mpXmlDoc, "//xhtml:p/xhtml:a/xhtml:span", "http://libreoffice.org");
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:a", "href", "http://libreoffice.org/");
+
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:p/xhtml:a/xhtml:span", "class");
+ CPPUNIT_ASSERT_EQUAL(OUString("#ff0000"), EPUBExportTest::getCss(aCssDoc, aClass, u"color"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTableWidth)
+{
+ createDoc(u"table-width.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:table", "class");
+ // This failed, relative total width of table was lost.
+ CPPUNIT_ASSERT_EQUAL(OUString("50%"), EPUBExportTest::getCss(aCssDoc, aClass, u"width"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTextBox)
+{
+ createDoc(u"text-box.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+
+ // This failed, image with caption was lost.
+ assertXPath(mpXmlDoc, "//xhtml:img", "class", "frame1");
+ // Expected spans:
+ // 1) break after the image
+ // 2) "Illustration "
+ // 3) The sequence field, this was missing (was ": foo" instead).
+ assertXPathContent(mpXmlDoc, "//xhtml:div/xhtml:p/xhtml:span[3]", "1");
+
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:div/xhtml:p/xhtml:span[3]", "class");
+ // This failed, the 3rd span was not italic.
+ CPPUNIT_ASSERT_EQUAL(OUString("italic"),
+ EPUBExportTest::getCss(aCssDoc, aClass, u"font-style"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testFontEmbedding)
+{
+#if !defined(MACOSX)
+ createDoc(u"font-embedding.fodt", {});
+
+ // Make sure that the params of defineEmbeddedFont() are all handled.
+ // librevenge:name
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ // 'SketchFlow Print' or ''SketchFlow Print1'
+ CPPUNIT_ASSERT(EPUBExportTest::getCss(aCssDoc, "font-face", u"font-family")
+ .startsWith("'SketchFlow Print"));
+ // librevenge:mime-type
+ mpXmlDoc = parseExport("OEBPS/content.opf");
+ assertXPath(mpXmlDoc, "/opf:package/opf:manifest/opf:item[@href='fonts/font0001.otf']",
+ "media-type", "application/vnd.ms-opentype");
+ // office:binary-data
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/fonts/font0001.otf"));
+ // librevenge:font-style
+ CPPUNIT_ASSERT_EQUAL(OUString("normal"),
+ EPUBExportTest::getCss(aCssDoc, "font-face", u"font-style"));
+ // librevenge:font-weight
+ CPPUNIT_ASSERT_EQUAL(OUString("normal"),
+ EPUBExportTest::getCss(aCssDoc, "font-face", u"font-weight"));
+#endif
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testImageLink)
+{
+ createDoc(u"image-link.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // This failed, image was missing.
+ assertXPath(mpXmlDoc, "//xhtml:p/xhtml:a/xhtml:img", 1);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testFootnote)
+{
+ createDoc(u"footnote.fodt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // These were missing, footnote was lost.
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:p/xhtml:sup/xhtml:a", "type", "noteref");
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:aside", "type", "footnote");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testPopup)
+{
+ createDoc(u"popup.odt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // Test image popup anchor.
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:p[1]/xhtml:a", "type", "noteref");
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:p[1]/xhtml:a/xhtml:img", 1);
+ // Test image popup content.
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:aside[1]", "type", "footnote");
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:aside[1]/xhtml:img", 1);
+
+ // Test text popup anchor.
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:p[2]/xhtml:span/xhtml:a", "type", "noteref");
+ assertXPathContent(mpXmlDoc, "//xhtml:body/xhtml:p[2]/xhtml:span/xhtml:a", "link");
+ // Test text popup content.
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:aside[2]", "type", "footnote");
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:aside[2]/xhtml:img", 1);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testPopupMedia)
+{
+ // This is the same as testPopup(), but the links point to images in the
+ // default media directory, not in the document directory.
+ createDoc(u"popup-media.odt", {});
+
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // Test image popup anchor. This failed, number of XPath nodes was 0.
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:p[1]/xhtml:a", "type", "noteref");
+ assertXPath(mpXmlDoc, "//xhtml:body/xhtml:p[1]/xhtml:a/xhtml:img", 1);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testPopupAPI)
+{
+ // Make sure that the popup works with data from a media directory.
+ OUString aMediaDir = m_directories.getURLFromSrc(DATA_DIRECTORY) + "popup";
+ uno::Sequence<beans::PropertyValue> aFilterData(
+ comphelper::InitPropertySequence({ { "RVNGMediaDir", uno::Any(aMediaDir) } }));
+ createDoc(u"popup-api.odt", aFilterData);
+
+ // We have a non-empty anchor image.
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ OUString aAnchor = getXPath(mpXmlDoc, "//xhtml:body/xhtml:p[1]/xhtml:a/xhtml:img", "src");
+ CPPUNIT_ASSERT(!aAnchor.isEmpty());
+ // We have a non-empty popup image.
+ OUString aData = getXPath(mpXmlDoc, "//xhtml:body/xhtml:aside[1]/xhtml:img", "src");
+ CPPUNIT_ASSERT(!aData.isEmpty());
+ // The anchor is different from the popup image.
+ CPPUNIT_ASSERT(aAnchor != aData);
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testPageSize)
+{
+ uno::Sequence<beans::PropertyValue> aFilterData(comphelper::InitPropertySequence(
+ { { "EPUBLayoutMethod",
+ uno::Any(static_cast<sal_Int32>(libepubgen::EPUB_LAYOUT_METHOD_FIXED)) } }));
+ createDoc(u"hello.fodt", aFilterData);
+
+ // This failed, viewport was empty, so page size was lost.
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ // 21,59cm x 27.94cm (letter).
+ assertXPath(mpXmlDoc, "/xhtml:html/xhtml:head/xhtml:meta[@name='viewport']", "content",
+ "width=816, height=1056");
+
+ mpXmlDoc = parseExport("OEBPS/images/image0001.svg");
+ // This was 288mm, logic->logic conversion input was a pixel value.
+ assertXPath(mpXmlDoc, "/svg:svg", "width", "216mm");
+ assertXPath(mpXmlDoc, "/svg:svg", "height", "279mm");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testSVG)
+{
+ uno::Sequence<beans::PropertyValue> aFilterData(comphelper::InitPropertySequence(
+ { { "EPUBLayoutMethod",
+ uno::Any(static_cast<sal_Int32>(libepubgen::EPUB_LAYOUT_METHOD_FIXED)) } }));
+ createDoc(u"hello.fodt", aFilterData);
+
+ CPPUNIT_ASSERT(mxZipFile->hasByName("OEBPS/images/image0001.svg"));
+ uno::Reference<io::XInputStream> xInputStream(
+ mxZipFile->getByName("OEBPS/images/image0001.svg"), uno::UNO_QUERY);
+ std::unique_ptr<SvStream> pStream(utl::UcbStreamHelper::CreateStream(xInputStream, true));
+
+ SvMemoryStream aMemoryStream;
+ aMemoryStream.WriteStream(*pStream);
+ OString aExpected("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<svg");
+ CPPUNIT_ASSERT(aMemoryStream.GetSize() > o3tl::make_unsigned(aExpected.getLength()));
+
+ // This failed, there was a '<!DOCTYPE' line between the xml and the svg
+ // one, causing a validation error.
+ OString aActual(static_cast<const char*>(aMemoryStream.GetData()), aExpected.getLength());
+ CPPUNIT_ASSERT_EQUAL(aExpected, aActual);
+
+ // This failed, we used the xlink attribute namespace, but we did not
+ // define its URL.
+ mpXmlDoc = parseExport("OEBPS/images/image0001.svg");
+ assertXPathNSDef(mpXmlDoc, "/svg:svg", u"xlink", u"http://www.w3.org/1999/xlink");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTdf115623SingleWritingMode)
+{
+ // Simple page that has single writing mode should work.
+ createDoc(u"tdf115623-single-writing-mode.odt", {});
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:body", "class");
+ CPPUNIT_ASSERT_EQUAL(OUString("vertical-rl"),
+ EPUBExportTest::getCss(aCssDoc, aClass, u"writing-mode"));
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTdf115623SplitByChapter)
+{
+ createDoc(u"tdf115623-split-by-chapter.odt", {});
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ {
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:body", "class");
+ CPPUNIT_ASSERT_EQUAL(OUString("vertical-rl"),
+ EPUBExportTest::getCss(aCssDoc, aClass, u"writing-mode"));
+ }
+ // Split HTML should keep the same writing-mode.
+ {
+ mpXmlDoc = parseExport("OEBPS/sections/section0002.xhtml");
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:body", "class");
+ CPPUNIT_ASSERT_EQUAL(OUString("vertical-rl"),
+ EPUBExportTest::getCss(aCssDoc, aClass, u"writing-mode"));
+ }
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testTdf115623ManyPageSpans)
+{
+ createDoc(u"tdf115623-many-pagespans.odt", {});
+ std::map<OUString, std::vector<OUString>> aCssDoc = parseCss("OEBPS/styles/stylesheet.css");
+ // Two pages should have different writing modes.
+ {
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:body", "class");
+ CPPUNIT_ASSERT_EQUAL(OUString("vertical-rl"),
+ EPUBExportTest::getCss(aCssDoc, aClass, u"writing-mode"));
+ }
+ {
+ mpXmlDoc = parseExport("OEBPS/sections/section0002.xhtml");
+ OUString aClass = getXPath(mpXmlDoc, "//xhtml:body", "class");
+ CPPUNIT_ASSERT_EQUAL(OUString("horizontal-tb"),
+ EPUBExportTest::getCss(aCssDoc, aClass, u"writing-mode"));
+ }
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testSimpleRuby)
+{
+ createDoc(u"simple-ruby.odt", {});
+ mpXmlDoc = parseExport("OEBPS/sections/section0001.xhtml");
+ assertXPathContent(mpXmlDoc, "//xhtml:body/xhtml:p/xhtml:ruby/xhtml:span", "base text");
+ assertXPathContent(mpXmlDoc, "//xhtml:body/xhtml:p/xhtml:ruby/xhtml:rt", "ruby text");
+}
+
+CPPUNIT_TEST_FIXTURE(EPUBExportTest, testAbi11105)
+{
+ // This crashed because the paragraph style "P5" which had a master-page-name
+ // appeared in a table cell messed up page spans.
+ createDoc(u"abi11105.abw", {});
+}
+}
+
+CPPUNIT_PLUGIN_IMPLEMENT();
+
+/* vim:set shiftwidth=4 softtabstop=4 expandtab: */