diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 02716112..2bc94e40 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -98,6 +98,14 @@ jobs: build/test/odr_test build/test/Release/odr_test.exe + - name: Artifact .conan2/p dir + uses: actions/upload-artifact@v4 + with: + name: conan2-${{ matrix.os }}-${{ matrix.compiler }} + path: ~/.conan2/p + if-no-files-found: error + compression-level: 0 + docker: needs: build runs-on: ${{ matrix.os }} @@ -197,6 +205,12 @@ jobs: name: bin-${{ matrix.os }}-${{ matrix.compiler }} path: . + - name: Download .conan2/p dir + uses: actions/download-artifact@v4 + with: + name: conan2-${{ matrix.os }}-${{ matrix.compiler }} + path: ~/.conan2/p + - name: fix artifact permissions if: runner.os != 'Windows' run: chmod +x build/test/odr_test @@ -233,6 +247,42 @@ jobs: test/data/reference-output/odr-private/output \ build/test/output/odr-private/output + - name: tidy pdf2htmlEX test outputs + if: runner.os == 'Linux' + run: | + python3 -u test/scripts/tidy_output.py build/test/output/odr-public/output-pdf2htmlEX + python3 -u test/scripts/tidy_output.py build/test/output/odr-private/output-pdf2htmlEX + - name: Compare pdf2htmlEX public test results + if: runner.os == 'Linux' + run: | + python3 -u test/scripts/compare_output.py \ + --driver firefox \ + --max-workers 1 \ + test/data/reference-output/odr-public/output-pdf2htmlEX \ + build/test/output/odr-public/output-pdf2htmlEX + - name: Compare pdf2htmlEX private test results + if: runner.os == 'Linux' + run: | + python3 -u test/scripts/compare_output.py \ + --driver firefox \ + --max-workers 1 \ + test/data/reference-output/odr-public/output-pdf2htmlEX \ + build/test/output/odr-public/output-pdf2htmlEX + + # wvWare has no private test data + - name: tidy wvWare test outputs + if: runner.os == 'Linux' + run: | + python3 -u test/scripts/tidy_output.py build/test/output/odr-public/output-wvWare + - name: Compare wvWare public test results + if: runner.os == 'Linux' + run: | + python3 -u test/scripts/compare_output.py \ + --driver firefox \ + --max-workers 1 \ + test/data/reference-output/odr-public/output-wvWare \ + build/test/output/odr-public/output-wvWare + build-test-downstream: runs-on: ${{ matrix.os }} strategy: diff --git a/CMakeLists.txt b/CMakeLists.txt index c7f4a881..d804a522 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,9 +5,12 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +option(BUILD_SHARED_LIBS "Build using shared libraries" ON) option(ODR_TEST "enable tests" OFF) option(ODR_CLI "enable command line interface" ON) option(ODR_CLANG_TIDY "Run clang-tidy static analysis" OFF) +option(WITH_PDF2HTMLEX "Build with pdf2htmlEX" ON) +option(WITH_WVWARE "Build with wvWare" ON) # TODO defining global compiler flags seems to be bad practice with conan # TODO consider using conan profiles @@ -37,6 +40,7 @@ find_package(uchardet REQUIRED) find_package(utf8cpp REQUIRED) configure_file("src/odr/internal/project_info.cpp.in" "src/odr/internal/project_info.cpp") +configure_file("src/odr/internal/project_info.hpp.in" "src/odr/internal/project_info.hpp") set(PRE_CONFIGURE_FILE "src/odr/internal/git_info.cpp.in") set(POST_CONFIGURE_FILE "${CMAKE_CURRENT_BINARY_DIR}/src/odr/internal/git_info.cpp") @@ -177,6 +181,7 @@ set_target_properties(odr PROPERTIES OUTPUT_NAME odr) target_include_directories(odr PUBLIC src + ${CMAKE_CURRENT_BINARY_DIR}/src ) target_link_libraries(odr PRIVATE @@ -189,6 +194,17 @@ target_link_libraries(odr utf8::cpp ) +if(WITH_PDF2HTMLEX) + target_sources(odr PRIVATE "src/odr/internal/html/pdf2htmlEX_wrapper.cpp") + find_package(pdf2htmlEX REQUIRED) + target_link_libraries(odr PRIVATE pdf2htmlex::pdf2htmlex) +endif(WITH_PDF2HTMLEX) +if(WITH_WVWARE) + target_sources(odr PRIVATE "src/odr/internal/html/wvWare_wrapper.cpp") + find_package(wvware REQUIRED) + target_link_libraries(odr PRIVATE wvware::wvware) +endif(WITH_WVWARE) + if (EXISTS "${PROJECT_SOURCE_DIR}/.git") add_dependencies(odr check_git) endif () @@ -206,8 +222,8 @@ if (ODR_CLANG_TIDY) endif () install( - DIRECTORY src/ - DESTINATION include/ + DIRECTORY src/ ${CMAKE_CURRENT_BINARY_DIR}/src/ + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} FILES_MATCHING PATTERN "*.hpp" ) install( diff --git a/conanfile.py b/conanfile.py index 5307b02e..54ad2160 100644 --- a/conanfile.py +++ b/conanfile.py @@ -3,6 +3,8 @@ from conan import ConanFile from conan.tools.build import check_min_cppstd from conan.tools.cmake import CMakeToolchain, CMakeDeps, CMake +from conan.tools.env import Environment +from conan.tools.env.environment import EnvVars from conan.tools.files import copy @@ -19,6 +21,8 @@ class OpenDocumentCoreConan(ConanFile): options = { "shared": [True, False], "fPIC": [True, False], + "with_pdf2htmlEX": [True, False], + "with_wvWare": [True, False], } default_options = { "shared": False, @@ -33,6 +37,10 @@ def requirements(self): self.requires("vincentlaucsb-csv-parser/2.3.0") self.requires("uchardet/0.0.8") self.requires("utfcpp/4.0.4") + if self.options.get_safe("with_pdf2htmlEX"): + self.requires("pdf2htmlex/0.18.8.rc1-20240905-git") + if self.options.get_safe("with_wvWare"): + self.requires("wvware/1.2.9") def build_requirements(self): self.test_requires("gtest/1.14.0") @@ -47,6 +55,9 @@ def config_options(self): if self.settings.os == "Windows": del self.options.fPIC + self.options.with_pdf2htmlEX = self.settings.os not in ["Windows", "Macos"] + self.options.with_wvWare = self.settings.os not in ["Windows", "Macos"] + def configure(self): if self.options.shared: self.options.rm_safe("fPIC") @@ -55,6 +66,20 @@ def generate(self): tc = CMakeToolchain(self) tc.variables["CMAKE_PROJECT_VERSION"] = self.version tc.variables["ODR_TEST"] = False + tc.variables["WITH_PDF2HTMLEX"] = self.options.get_safe("with_pdf2htmlEX", False) + tc.variables["WITH_WVWARE"] = self.options.get_safe("with_wvWare", False) + + # Get runenv info, exported by package_info() of dependencies + # We need to obtain PDF2HTMLEX_DATA_DIR, POPPLER_DATA_DIR, FONTCONFIG_PATH and WVDATADIR + runenv_info = Environment() + deps = self.dependencies.host.topological_sort + deps = [dep for dep in reversed(deps.values())] + for dep in deps: + runenv_info.compose_env(dep.runenv_info) + envvars = runenv_info.vars(self) + for v in ["PDF2HTMLEX_DATA_DIR", "POPPLER_DATA_DIR", "FONTCONFIG_PATH", "WVDATADIR"]: + tc.variables[v] = envvars.get(v) + tc.generate() deps = CMakeDeps(self) @@ -66,13 +91,6 @@ def build(self): cmake.build() def package(self): - copy( - self, - "*.hpp", - src=os.path.join(self.recipe_folder, "src"), - dst=os.path.join(self.export_sources_folder, "include"), - ) - cmake = CMake(self) cmake.install() diff --git a/src/odr/internal/html/pdf2htmlEX_wrapper.cpp b/src/odr/internal/html/pdf2htmlEX_wrapper.cpp new file mode 100644 index 00000000..169821f8 --- /dev/null +++ b/src/odr/internal/html/pdf2htmlEX_wrapper.cpp @@ -0,0 +1,64 @@ +#include + +#include +#include +#include + +#include + +#include + +#include +#include + +namespace odr::internal { + +Html html::pdf2htmlEX_wrapper(const std::string &input_path, + const std::string &output_path, + const HtmlConfig &config, + std::optional &password) { + static const char *fontconfig_path = getenv("FONTCONFIG_PATH"); + if (nullptr == fontconfig_path) { + // Storage is allocated and after successful putenv, it will never be freed. + // This is the way of putenv. + char *storage = strdup("FONTCONFIG_PATH=" FONTCONFIG_PATH); + if (0 != putenv(storage)) { + free(storage); + } + fontconfig_path = getenv("FONTCONFIG_PATH"); + } + + pdf2htmlEX::pdf2htmlEX pdf2htmlEX; + pdf2htmlEX.setDataDir(PDF2HTMLEX_DATA_DIR); + pdf2htmlEX.setPopplerDataDir(POPPLER_DATA_DIR); + + pdf2htmlEX.setInputFilename(input_path); + pdf2htmlEX.setDestinationDir(output_path); + auto output_file_name = "document.html"; + pdf2htmlEX.setOutputFilename(output_file_name); + + pdf2htmlEX.setDRM(false); + pdf2htmlEX.setProcessOutline(false); + pdf2htmlEX.setProcessAnnotation(true); + + if (password.has_value()) { + pdf2htmlEX.setOwnerPassword(password.value()); + pdf2htmlEX.setUserPassword(password.value()); + } + + try { + pdf2htmlEX.convert(); + } catch (const pdf2htmlEX::EncryptionPasswordException &e) { + throw WrongPassword(); + } catch (const pdf2htmlEX::DocumentCopyProtectedException &e) { + throw std::runtime_error("document is copy protected"); + } catch (const pdf2htmlEX::ConversionFailedException &e) { + throw std::runtime_error(std::string("conversion error ") + e.what()); + } + + return {FileType::portable_document_format, + config, + {{"document", output_path + "/" + output_file_name}}}; +} + +} // namespace odr::internal diff --git a/src/odr/internal/html/pdf2htmlEX_wrapper.hpp b/src/odr/internal/html/pdf2htmlEX_wrapper.hpp new file mode 100644 index 00000000..ace0e5ce --- /dev/null +++ b/src/odr/internal/html/pdf2htmlEX_wrapper.hpp @@ -0,0 +1,23 @@ +#ifndef ODR_INTERNAL_PDF2HTMLEX_WRAPPER_HPP +#define ODR_INTERNAL_PDF2HTMLEX_WRAPPER_HPP + +#include +#include + +namespace odr { +class PdfFile; + +struct HtmlConfig; +class Html; +} // namespace odr + +namespace odr::internal::html { + +Html pdf2htmlEX_wrapper(const std::string &input_path, + const std::string &output_path, + const HtmlConfig &config, + std::optional &password); + +} + +#endif // ODR_INTERNAL_PDF2HTMLEX_WRAPPER_HPP diff --git a/src/odr/internal/html/wvWare_wrapper.cpp b/src/odr/internal/html/wvWare_wrapper.cpp new file mode 100644 index 00000000..1908836f --- /dev/null +++ b/src/odr/internal/html/wvWare_wrapper.cpp @@ -0,0 +1,52 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace odr::internal::html { + +Html wvWare_wrapper(const std::string &input_path, + const std::string &output_path, const HtmlConfig &config, + std::optional &password) { + if (nullptr == g_wvDataDir) { + g_wvDataDir = WVDATADIR; + } + + auto output_file_path = output_path + "/document.html"; + + char *input_file_path = strdup(input_path.c_str()); + char *output_dir = strdup(output_path.c_str()); + + g_htmlOutputFileHandle = fopen(output_file_path.c_str(), "w"); + + std::string pw; + if (password.has_value()) { + pw = password.value(); + } + int retVal = wvHtml_convert(input_file_path, output_dir, pw.c_str()); + free(output_dir); + free(input_file_path); + fclose(g_htmlOutputFileHandle); + g_htmlOutputFileHandle = nullptr; + + if (0 != retVal) { + unlink(output_file_path.c_str()); + + switch (retVal) { + case 100: // PasswordRequired + case 101: // Wrong Password + throw WrongPassword(); + default: + throw std::runtime_error("Conversion error"); + } + } + + return { + FileType::legacy_word_document, config, {{"document", output_file_path}}}; +} + +} // namespace odr::internal::html diff --git a/src/odr/internal/html/wvWare_wrapper.hpp b/src/odr/internal/html/wvWare_wrapper.hpp new file mode 100644 index 00000000..e7000901 --- /dev/null +++ b/src/odr/internal/html/wvWare_wrapper.hpp @@ -0,0 +1,22 @@ +#ifndef ODR_INTERNAL_WVWARE_WRAPPER_HPP +#define ODR_INTERNAL_WVWARE_WRAPPER_HPP + +#include +#include + +namespace odr { +class File; + +struct HtmlConfig; +class Html; +} // namespace odr + +namespace odr::internal::html { + +Html wvWare_wrapper(const std::string &input_path, + const std::string &output_path, const HtmlConfig &config, + std::optional &password); + +} + +#endif // ODR_INTERNAL_WVWARE_WRAPPER_HPP diff --git a/src/odr/internal/project_info.hpp b/src/odr/internal/project_info.hpp deleted file mode 100644 index 2c7cfd66..00000000 --- a/src/odr/internal/project_info.hpp +++ /dev/null @@ -1,8 +0,0 @@ -#ifndef ODR_INTERNAL_PROJECT_INFO_HPP -#define ODR_INTERNAL_PROJECT_INFO_HPP - -namespace odr::internal::project_info { -const char *version() noexcept; -} // namespace odr::internal::project_info - -#endif // ODR_INTERNAL_PROJECT_INFO_HPP diff --git a/src/odr/internal/project_info.hpp.in b/src/odr/internal/project_info.hpp.in new file mode 100644 index 00000000..0af208e4 --- /dev/null +++ b/src/odr/internal/project_info.hpp.in @@ -0,0 +1,15 @@ +#ifndef ODR_INTERNAL_PROJECT_INFO_HPP +#define ODR_INTERNAL_PROJECT_INFO_HPP + +namespace odr::internal::project_info { +const char *version() noexcept; +} // namespace odr::internal::project_info + +#cmakedefine WITH_PDF2HTMLEX 1 +#cmakedefine PDF2HTMLEX_DATA_DIR "@PDF2HTMLEX_DATA_DIR@" +#cmakedefine POPPLER_DATA_DIR "@POPPLER_DATA_DIR@" +#cmakedefine FONTCONFIG_PATH "@FONTCONFIG_PATH@" +#cmakedefine WITH_WVWARE 1 +#cmakedefine WVDATADIR "@WVDATADIR@" + +#endif // ODR_INTERNAL_PROJECT_INFO_HPP diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 5142fc9c..e8f623e9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -62,4 +62,14 @@ target_link_libraries(odr_test odr ) + +if(WITH_PDF2HTMLEX) + target_sources(odr_test PRIVATE "src/pdf2htmlEX_wrapper_test.cpp") + target_link_libraries(odr_test PRIVATE pdf2htmlex::pdf2htmlex) +endif(WITH_PDF2HTMLEX) +if(WITH_WVWARE) + target_sources(odr_test PRIVATE "src/wvWare_wrapper_test.cpp") + target_link_libraries(odr_test PRIVATE wvware::wvware) +endif(WITH_WVWARE) + gtest_add_tests(TARGET odr_test) diff --git a/test/data/input/odr-private b/test/data/input/odr-private index 508550b9..a997171b 160000 --- a/test/data/input/odr-private +++ b/test/data/input/odr-private @@ -1 +1 @@ -Subproject commit 508550b99ed8f2300b33baba0219468ea1ba4c5d +Subproject commit a997171b727f230c4a81421d43e2ed62f37b94ca diff --git a/test/data/reference-output/odr-private b/test/data/reference-output/odr-private index 1b54e452..b1d06179 160000 --- a/test/data/reference-output/odr-private +++ b/test/data/reference-output/odr-private @@ -1 +1 @@ -Subproject commit 1b54e452350216edfe09dfd697af002add29fa87 +Subproject commit b1d061790ee59b5ded4c3b970dd0a5c453d65b96 diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index 6138deea..c3b3d0b1 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit 6138deea822cc17e940181fe3d99b6b6aef64551 +Subproject commit c3b3d0b160c4bb34ee3ca9b7e61cff504335cbc5 diff --git a/test/scripts/html_render_diff.py b/test/scripts/html_render_diff.py index 5a601263..a945e78f 100755 --- a/test/scripts/html_render_diff.py +++ b/test/scripts/html_render_diff.py @@ -5,8 +5,13 @@ import sys import argparse import io +import time + from PIL import Image, ImageChops from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.support.ui import WebDriverWait import pathlib @@ -19,8 +24,25 @@ def to_url(something): def screenshot(browser, url): browser.get(url) - body = browser.find_element('tag name', 'body') - png = body.screenshot_as_png + target_find_by = By.TAG_NAME + target = 'body' + loaded_page_settling_time = 0 + + # Selenium doesn't like when we try to screenshot element of documents generated by pdf2htmlEX + if 'output-pdf2htmlEX' in url: + target_find_by = By.ID + target = 'page-container' + loaded_page_settling_time = 1 + + web_driver_wait = WebDriverWait(browser, 5) + web_driver_wait.until(expected_conditions.presence_of_element_located((target_find_by, target))) + web_driver_wait.until(lambda driver: driver.execute_script("return document.readyState") == "complete") + if loaded_page_settling_time != 0: + time.sleep(loaded_page_settling_time) + + target_element = browser.find_element(target_find_by, target) + + png = target_element.screenshot_as_png return Image.open(io.BytesIO(png)) diff --git a/test/src/pdf2htmlEX_wrapper_test.cpp b/test/src/pdf2htmlEX_wrapper_test.cpp new file mode 100644 index 00000000..e29c82c3 --- /dev/null +++ b/test/src/pdf2htmlEX_wrapper_test.cpp @@ -0,0 +1,68 @@ +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +using namespace odr; +using namespace odr::test; +using namespace odr::internal; +using namespace odr::test; +namespace fs = std::filesystem; + +using pdf2htmlEXWrapperTests = ::testing::TestWithParam; + +TEST_P(pdf2htmlEXWrapperTests, html) { + const std::string test_file_path = GetParam(); + const TestFile test_file = TestData::test_file(test_file_path); + + const std::string test_repo = *common::Path(test_file_path).begin(); + const std::string output_path_prefix = + common::Path("output").join(test_repo).join("output-pdf2htmlEX").string(); + const std::string output_path = + common::Path(output_path_prefix) + .join(common::Path(test_file_path).rebase(test_repo)) + .string(); + + std::cout << test_file.path << " to " << output_path << std::endl; + + fs::create_directories(output_path); + HtmlConfig config; + std::optional password; + + if (test_file.password_encrypted) { + password = test_file.password; + } + // @TODO: why does test_file.password_encrypted == false for this file?? + else if (test_file.path.ends_with("encrypted_fontfile3_opentype.pdf")) { + password = "sample-user-password"; + } + + Html html = odr::internal::html::pdf2htmlEX_wrapper( + test_file.path, output_path, config, password); + for (const HtmlPage &html_page : html.pages()) { + EXPECT_TRUE(fs::is_regular_file(html_page.path)); + EXPECT_LT(0, fs::file_size(html_page.path)); + } +} + +INSTANTIATE_TEST_SUITE_P(pdf2htmlEX_test_files, pdf2htmlEXWrapperTests, + testing::ValuesIn(TestData::test_file_paths( + FileType::portable_document_format)), + [](const ::testing::TestParamInfo &info) { + std::string path = info.param; + internal::util::string::replace_all(path, "/", "_"); + internal::util::string::replace_all(path, "-", "_"); + internal::util::string::replace_all(path, "+", "_"); + internal::util::string::replace_all(path, ".", "_"); + internal::util::string::replace_all(path, " ", "_"); + internal::util::string::replace_all(path, "$", ""); + return path; + }); diff --git a/test/src/test_util.cpp b/test/src/test_util.cpp index ca9b9e91..764ad4ea 100644 --- a/test/src/test_util.cpp +++ b/test/src/test_util.cpp @@ -122,6 +122,10 @@ std::vector TestData::test_file_paths() { return instance_().test_file_paths_(); } +std::vector TestData::test_file_paths(FileType fileType) { + return instance_().test_file_paths_(fileType); +} + TestFile TestData::test_file(const std::string &path) { return instance_().test_file_(path); } @@ -141,6 +145,17 @@ std::vector TestData::test_file_paths_() const { return result; } +std::vector TestData::test_file_paths_(FileType fileType) const { + std::vector result; + for (auto &&file : m_test_files) { + if (file.second.type == fileType) { + result.push_back(file.first); + } + } + std::sort(std::begin(result), std::end(result)); + return result; +} + TestFile TestData::test_file_(const std::string &path) const { return m_test_files.at(path); } diff --git a/test/src/test_util.hpp b/test/src/test_util.hpp index e13ac160..324f76c4 100644 --- a/test/src/test_util.hpp +++ b/test/src/test_util.hpp @@ -26,6 +26,7 @@ class TestData { static std::string data_input_directory(); static std::vector test_file_paths(); + static std::vector test_file_paths(FileType); static TestFile test_file(const std::string &path); static std::string test_file_path(const std::string &path); @@ -39,6 +40,7 @@ class TestData { static TestData &instance_(); std::vector test_file_paths_() const; + std::vector test_file_paths_(FileType) const; TestFile test_file_(const std::string &path) const; std::unordered_map m_test_files; diff --git a/test/src/wvWare_wrapper_test.cpp b/test/src/wvWare_wrapper_test.cpp new file mode 100644 index 00000000..d3d45252 --- /dev/null +++ b/test/src/wvWare_wrapper_test.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +using namespace odr; +using namespace odr::test; +using namespace odr::internal; +using namespace odr::test; +namespace fs = std::filesystem; + +using wvWareWrapperTests = ::testing::TestWithParam; + +TEST_P(wvWareWrapperTests, html) { + const std::string test_file_path = GetParam(); + const TestFile test_file = TestData::test_file(test_file_path); + + const std::string test_repo = *common::Path(test_file_path).begin(); + const std::string output_path_prefix = + common::Path("output").join(test_repo).join("output-wvWare").string(); + const std::string output_path = + common::Path(output_path_prefix) + .join(common::Path(test_file_path).rebase(test_repo)) + .string(); + + std::cout << test_file.path << " to " << output_path << std::endl; + + // Password protected files are problematic on wvWare + if (test_file.password_encrypted) { + GTEST_SKIP(); + } + + fs::create_directories(output_path); + HtmlConfig config; + std::optional password; + Html html = odr::internal::html::wvWare_wrapper(test_file.path, output_path, + config, password); + + for (const HtmlPage &html_page : html.pages()) { + EXPECT_TRUE(fs::is_regular_file(html_page.path)); + EXPECT_LT(0, fs::file_size(html_page.path)); + } +} + +INSTANTIATE_TEST_SUITE_P(wvWare_test_files, wvWareWrapperTests, + testing::ValuesIn(TestData::test_file_paths( + FileType::legacy_word_document)), + [](const ::testing::TestParamInfo &info) { + std::string path = info.param; + internal::util::string::replace_all(path, "/", "_"); + internal::util::string::replace_all(path, "-", "_"); + internal::util::string::replace_all(path, "+", "_"); + internal::util::string::replace_all(path, ".", "_"); + internal::util::string::replace_all(path, " ", "_"); + internal::util::string::replace_all(path, "$", ""); + return path; + });