diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 355b8d2b..f7eb13d4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,7 +59,7 @@ jobs: - x64-windows-static - x86-windows-static - arm64-windows-static - runs-on: windows-latest + runs-on: windows-2022 env: VCPKG_DEFAULT_TRIPLET: ${{matrix.triplet}} VCPKG_DEFAULT_HOST_TRIPLET: ${{startsWith(matrix.triplet, 'arm64-') && 'x64-windows-static-md' || matrix.triplet}} @@ -221,7 +221,7 @@ jobs: - x64-windows-static - x86-windows-static - arm64-windows-static - runs-on: windows-latest + runs-on: windows-2022 needs: deps-windows env: VCPKG_DEFAULT_TRIPLET: ${{matrix.triplet}} @@ -326,7 +326,7 @@ jobs: build/*.log windows-ninja-clang-x86-64: - runs-on: windows-latest + runs-on: windows-2022 needs: deps-windows env: VCPKG_DEFAULT_TRIPLET: x64-windows-static-md @@ -364,7 +364,7 @@ jobs: build/*.log windows-vs-x86: - runs-on: windows-latest + runs-on: windows-2022 needs: deps-windows env: VCPKG_DEFAULT_TRIPLET: x86-windows-static-md @@ -429,7 +429,7 @@ jobs: build/*.log windows-vs-clangcl-x86_64: - runs-on: windows-latest + runs-on: windows-2022 needs: deps-windows env: VCPKG_DEFAULT_TRIPLET: x64-windows-static @@ -499,7 +499,7 @@ jobs: build/*.log macos-ninja-clang-x86_64: - runs-on: macos-13 + runs-on: macos-15-intel needs: deps-macos env: VCPKG_DEFAULT_TRIPLET: x64-osx @@ -647,7 +647,7 @@ jobs: - x64-windows-static - x86-windows-static - arm64-windows-static - runs-on: windows-latest + runs-on: windows-2022 needs: build-windows steps: - name: Download artifacts diff --git a/CMakeLists.txt b/CMakeLists.txt index b0ffdd04..f52796c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -272,6 +272,7 @@ endif () install( TARGETS brisk-core + brisk-text-engine brisk-graphics brisk-network brisk-window diff --git a/cmake/ports/dawn/abseil-apple-architecture.patch b/cmake/ports/dawn/abseil-apple-architecture.patch new file mode 100644 index 00000000..8c5b256f --- /dev/null +++ b/cmake/ports/dawn/abseil-apple-architecture.patch @@ -0,0 +1,29 @@ +diff --git a/third_party/abseil-cpp/absl/copts/AbseilConfigureCopts.cmake b/third_party/abseil-cpp/absl/copts/AbseilConfigureCopts.cmake +index 9a88c9e9f..000000000 100644 +--- a/third_party/abseil-cpp/absl/copts/AbseilConfigureCopts.cmake ++++ b/third_party/abseil-cpp/absl/copts/AbseilConfigureCopts.cmake +@@ -40,10 +40,16 @@ if(APPLE AND CMAKE_CXX_COMPILER_ID MATCHES [[Clang]]) + set(ABSL_RANDOM_RANDEN_COPTS "") +- foreach(_arch IN ITEMS "x86_64" "arm64") +- string(TOUPPER "${_arch}" _arch_uppercase) +- string(REPLACE "X86_64" "X64" _arch_uppercase ${_arch_uppercase}) +- foreach(_flag IN LISTS ABSL_RANDOM_HWAES_${_arch_uppercase}_FLAGS) +- list(APPEND ABSL_RANDOM_RANDEN_COPTS "-Xarch_${_arch}" "${_flag}") +- endforeach() +- endforeach() ++ if(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") ++ set(ABSL_RANDOM_RANDEN_COPTS "${ABSL_RANDOM_HWAES_X64_FLAGS}") ++ elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") ++ set(ABSL_RANDOM_RANDEN_COPTS "${ABSL_RANDOM_HWAES_ARM64_FLAGS}") ++ else() ++ foreach(_arch IN ITEMS "x86_64" "arm64") ++ string(TOUPPER "${_arch}" _arch_uppercase) ++ string(REPLACE "X86_64" "X64" _arch_uppercase ${_arch_uppercase}) ++ foreach(_flag IN LISTS ABSL_RANDOM_HWAES_${_arch_uppercase}_FLAGS) ++ list(APPEND ABSL_RANDOM_RANDEN_COPTS "-Xarch_${_arch}" "${_flag}") ++ endforeach() ++ endforeach() ++ endif() + # If a compiler happens to deal with an argument for a currently unused + # architecture, it will warn about an unused command line argument. + option(ABSL_RANDOM_RANDEN_COPTS_WARNING OFF diff --git a/cmake/ports/dawn/portfile.cmake b/cmake/ports/dawn/portfile.cmake index 274fb3e5..de040b91 100644 --- a/cmake/ports/dawn/portfile.cmake +++ b/cmake/ports/dawn/portfile.cmake @@ -18,6 +18,11 @@ vcpkg_add_to_path("${GIT_EXE_PATH}") execute_process(COMMAND ${PYTHON3} tools/fetch_dawn_dependencies.py WORKING_DIRECTORY ${SOURCE_PATH}) +vcpkg_apply_patches( + SOURCE_PATH "${SOURCE_PATH}" + PATCHES abseil-apple-architecture.patch +) + set(DESKTOP_GL OFF) set(USE_WAYLAND OFF) if (VCPKG_TARGET_IS_LINUX) diff --git a/cmake/ports/sheenbidi/portfile.cmake b/cmake/ports/sheenbidi/portfile.cmake new file mode 100644 index 00000000..76a393fc --- /dev/null +++ b/cmake/ports/sheenbidi/portfile.cmake @@ -0,0 +1,28 @@ +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO Tehreer/SheenBidi + REF "v${VERSION}" + SHA512 67c8ef7bea9fc677fbb83601403b40bcc274842597df53a699fd5758f4f170ac5d1fc9a719d590da25f6a72769fe59a2a1cf57e54f0ef6859561bfb77c0c72c4 +) + +vcpkg_check_features( + OUT_FEATURE_OPTIONS FEATURE_OPTIONS + FEATURES + "text-api" SB_CONFIG_EXPERIMENTAL_TEXT_API +) + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + ${FEATURE_OPTIONS} + -DSB_CONFIG_UNITY=ON +) + +vcpkg_cmake_install() +vcpkg_cmake_config_fixup(CONFIG_PATH lib/cmake/SheenBidi) +vcpkg_fixup_pkgconfig() +vcpkg_copy_pdbs() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/cmake/ports/sheenbidi/vcpkg.json b/cmake/ports/sheenbidi/vcpkg.json new file mode 100644 index 00000000..af475bcc --- /dev/null +++ b/cmake/ports/sheenbidi/vcpkg.json @@ -0,0 +1,22 @@ +{ + "name": "sheenbidi", + "version": "3.0.0", + "description": "SheenBidi is a lightweight, fast and stable implementation of the Unicode Bidirectional Algorithm.", + "homepage": "https://github.com/Tehreer/SheenBidi", + "license": "Apache-2.0", + "dependencies": [ + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + } + ], + "features": { + "text-api": { + "description": "Enable the experimental text editing and analysis API" + } + } +} diff --git a/cmake/toolchains/linux.cmake b/cmake/toolchains/linux.cmake index 3239620a..c61a0178 100644 --- a/cmake/toolchains/linux.cmake +++ b/cmake/toolchains/linux.cmake @@ -1,11 +1,23 @@ if (NOT _VCPKG_LINUX_TOOLCHAIN_OVERRIDE) set(_VCPKG_LINUX_TOOLCHAIN_OVERRIDE 1) - - if (Z_VCPKG_ROOT_DIR) - include(${Z_VCPKG_ROOT_DIR}/scripts/toolchains/linux.cmake) - else() - include(${_VCPKG_ROOT_DIR}/scripts/toolchains/linux.cmake) - endif() + + # Prefer the root discovered by the vcpkg toolchain. Keep the environment + # and cache-variable fallbacks for older vcpkg versions and standalone use. + if (DEFINED Z_VCPKG_ROOT_DIR AND NOT "${Z_VCPKG_ROOT_DIR}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${Z_VCPKG_ROOT_DIR}") + elseif (DEFINED ENV{VCPKG_ROOT} AND NOT "$ENV{VCPKG_ROOT}" STREQUAL "") + file(TO_CMAKE_PATH "$ENV{VCPKG_ROOT}" _BRISK_VCPKG_ROOT_DIR) + elseif (DEFINED VCPKG_ROOT AND NOT "${VCPKG_ROOT}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${VCPKG_ROOT}") + elseif (DEFINED _VCPKG_ROOT_DIR AND NOT "${_VCPKG_ROOT_DIR}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${_VCPKG_ROOT_DIR}") + endif () + + if (NOT EXISTS "${_BRISK_VCPKG_ROOT_DIR}/scripts/toolchains/linux.cmake") + message(FATAL_ERROR "Could not locate the vcpkg Linux toolchain. Set VCPKG_ROOT or use a vcpkg toolchain that defines Z_VCPKG_ROOT_DIR.") + endif () + + include("${_BRISK_VCPKG_ROOT_DIR}/scripts/toolchains/linux.cmake") string(APPEND CMAKE_C_FLAGS_RELEASE_INIT " -g0 -DNDEBUG -O3 ") string(APPEND CMAKE_CXX_FLAGS_RELEASE_INIT " -g0 -DNDEBUG -O3 ") @@ -32,4 +44,5 @@ if (NOT _VCPKG_LINUX_TOOLCHAIN_OVERRIDE) "${CMAKE_CXX_FLAGS_DEBUG_INIT}" CACHE STRING "" FORCE) + unset(_BRISK_VCPKG_ROOT_DIR) endif () diff --git a/cmake/toolchains/osx.cmake b/cmake/toolchains/osx.cmake index 94bafaf2..a337f4e7 100644 --- a/cmake/toolchains/osx.cmake +++ b/cmake/toolchains/osx.cmake @@ -1,11 +1,23 @@ if (NOT _VCPKG_OSX_TOOLCHAIN_OVERRIDE) set(_VCPKG_OSX_TOOLCHAIN_OVERRIDE 1) - - if (Z_VCPKG_ROOT_DIR) - include(${Z_VCPKG_ROOT_DIR}/scripts/toolchains/osx.cmake) - else() - include(${_VCPKG_ROOT_DIR}/scripts/toolchains/osx.cmake) - endif() + + # Prefer the root discovered by the vcpkg toolchain. Keep the environment + # and cache-variable fallbacks for older vcpkg versions and standalone use. + if (DEFINED Z_VCPKG_ROOT_DIR AND NOT "${Z_VCPKG_ROOT_DIR}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${Z_VCPKG_ROOT_DIR}") + elseif (DEFINED ENV{VCPKG_ROOT} AND NOT "$ENV{VCPKG_ROOT}" STREQUAL "") + file(TO_CMAKE_PATH "$ENV{VCPKG_ROOT}" _BRISK_VCPKG_ROOT_DIR) + elseif (DEFINED VCPKG_ROOT AND NOT "${VCPKG_ROOT}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${VCPKG_ROOT}") + elseif (DEFINED _VCPKG_ROOT_DIR AND NOT "${_VCPKG_ROOT_DIR}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${_VCPKG_ROOT_DIR}") + endif () + + if (NOT EXISTS "${_BRISK_VCPKG_ROOT_DIR}/scripts/toolchains/osx.cmake") + message(FATAL_ERROR "Could not locate the vcpkg macOS toolchain. Set VCPKG_ROOT or use a vcpkg toolchain that defines Z_VCPKG_ROOT_DIR.") + endif () + + include("${_BRISK_VCPKG_ROOT_DIR}/scripts/toolchains/osx.cmake") string(APPEND CMAKE_C_FLAGS_RELEASE_INIT " -g0 -DNDEBUG -O3 ") string(APPEND CMAKE_CXX_FLAGS_RELEASE_INIT " -g0 -DNDEBUG -O3 ") @@ -32,4 +44,5 @@ if (NOT _VCPKG_OSX_TOOLCHAIN_OVERRIDE) "${CMAKE_CXX_FLAGS_DEBUG_INIT}" CACHE STRING "" FORCE) + unset(_BRISK_VCPKG_ROOT_DIR) endif () diff --git a/cmake/toolchains/windows.cmake b/cmake/toolchains/windows.cmake index 4e5f9225..4f442aa7 100644 --- a/cmake/toolchains/windows.cmake +++ b/cmake/toolchains/windows.cmake @@ -1,7 +1,24 @@ if (NOT _VCPKG_WINDOWS_TOOLCHAIN_OVERRIDE) set(_VCPKG_WINDOWS_TOOLCHAIN_OVERRIDE 1) - include($ENV{VCPKG_ROOT}/scripts/toolchains/windows.cmake) + # Recent vcpkg versions derive the root from .vcpkg-root and expose it as + # Z_VCPKG_ROOT_DIR. VCPKG_ROOT is not guaranteed to remain in the + # environment while a chainload toolchain is being evaluated. + if (DEFINED Z_VCPKG_ROOT_DIR AND NOT "${Z_VCPKG_ROOT_DIR}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${Z_VCPKG_ROOT_DIR}") + elseif (DEFINED ENV{VCPKG_ROOT} AND NOT "$ENV{VCPKG_ROOT}" STREQUAL "") + file(TO_CMAKE_PATH "$ENV{VCPKG_ROOT}" _BRISK_VCPKG_ROOT_DIR) + elseif (DEFINED VCPKG_ROOT AND NOT "${VCPKG_ROOT}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${VCPKG_ROOT}") + elseif (DEFINED _VCPKG_ROOT_DIR AND NOT "${_VCPKG_ROOT_DIR}" STREQUAL "") + set(_BRISK_VCPKG_ROOT_DIR "${_VCPKG_ROOT_DIR}") + endif () + + if (NOT EXISTS "${_BRISK_VCPKG_ROOT_DIR}/scripts/toolchains/windows.cmake") + message(FATAL_ERROR "Could not locate the vcpkg Windows toolchain. Set VCPKG_ROOT or use a vcpkg toolchain that defines Z_VCPKG_ROOT_DIR.") + endif () + + include("${_BRISK_VCPKG_ROOT_DIR}/scripts/toolchains/windows.cmake") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Zc:inline") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Zc:inline") @@ -45,6 +62,16 @@ if (NOT _VCPKG_WINDOWS_TOOLCHAIN_OVERRIDE) CACHE PATH "" FORCE) endif () + # vcpkg's Windows toolchain adds /c65001 to CMAKE_RC_FLAGS. That option + # is accepted by Microsoft's resource compiler, but CMake's resource + # dependency scanner invokes clang-cl with the same flags and clang-cl + # treats /c65001 as an input file. Keep the charset option for MSVC and + # omit it for clang-cl builds. + if (DEFINED ENV{LLVM_DIR} OR CMAKE_C_COMPILER MATCHES "clang-cl" OR CMAKE_CXX_COMPILER MATCHES "clang-cl") + set(CMAKE_RC_FLAGS "/DWIN32" + CACHE STRING "Flags used by the Windows resource compiler" FORCE) + endif () + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS}" CACHE STRING "" FORCE) @@ -65,4 +92,5 @@ if (NOT _VCPKG_WINDOWS_TOOLCHAIN_OVERRIDE) "${CMAKE_CXX_FLAGS_DEBUG}" CACHE STRING "" FORCE) + unset(_BRISK_VCPKG_ROOT_DIR) endif () diff --git a/examples/dashboard/src/dashboard.cpp b/examples/dashboard/src/dashboard.cpp index 08162fbf..29ef986f 100644 --- a/examples/dashboard/src/dashboard.cpp +++ b/examples/dashboard/src/dashboard.cpp @@ -25,10 +25,8 @@ int briskMain() { using namespace Brisk; - fonts->addFont("Titillium", FontStyle::Normal, FontWeight::Regular, - Resources::loadCached("TitilliumWeb-Regular.ttf"), false); - fonts->addFont("Titillium", FontStyle::Normal, FontWeight::Bold, - Resources::loadCached("TitilliumWeb-Bold.ttf"), false); + std::ignore = fonts->addFontFromResource("TitilliumWeb-Regular.ttf", "Titillium"); + std::ignore = fonts->addFontFromResource("TitilliumWeb-Bold.ttf", "Titillium"); GuiApplication application; return application.run(createComponent()); diff --git a/examples/showcase/CMakeLists.txt b/examples/showcase/CMakeLists.txt index 5f9da205..fa9e045c 100644 --- a/examples/showcase/CMakeLists.txt +++ b/examples/showcase/CMakeLists.txt @@ -72,4 +72,9 @@ brisk_target_link_resource( INPUT "${PROJECT_SOURCE_DIR}/resources/countries.json" BROTLI) +brisk_target_link_resource( + showcase PRIVATE GoNotoKurrent-Regular.ttf + INPUT "${BRISK_RESOURCES_DIR}/fonts/GoNotoKurrent-Regular.ttf" + BROTLI) + brisk_setup_executable(showcase) diff --git a/examples/showcase/src/Buttons.cpp b/examples/showcase/src/Buttons.cpp index 3fe9a07f..4c5ef5d4 100644 --- a/examples/showcase/src/Buttons.cpp +++ b/examples/showcase/src/Buttons.cpp @@ -108,14 +108,15 @@ Rc ShowcaseButtons::build(Rc notifications, Value g rcnew Viewport{ [](Canvas& canvas, Rectangle rect) { canvas.setFillColor(Palette::Standard::amber); - PreparedText text = fonts->prepare(Font{ Font::DefaultPlusIconsEmoji, dp(18) }, - "This text is rendered dynamically."); - float x = fract(currentTime() * 0.1) * text.bounds().width(); - PointF offset = text.alignLines(0.f, 0.5f); - canvas.fillText(PointF{ -x, float(rect.center().y) } + offset, text); - canvas.fillText(PointF{ -x, float(rect.center().y) } + offset + - PointF{ text.bounds().width(), 0.f }, - text); + const ShapedText text = + fonts->shapeText(Font{ Font::DefaultPlusIconsEmoji, dp(18) }, + TextWithOptions{ "This text is rendered dynamically." }); + const TextLayout layout = text.layout(); + float x = fract(currentTime() * 0.1) * layout.bounds().width(); + const PointF position{ -x, float(rect.center().y) }; + canvas.fillText(position, { 0.f, 0.5f }, layout); + canvas.fillText(position + PointF{ layout.bounds().width(), 0.f }, { 0.f, 0.5f }, + layout); }, dimensions = { 70_apx, 25_apx }, }, @@ -156,7 +157,7 @@ Rc ShowcaseButtons::build(Rc notifications, Value g repeatDelay = 0.2, repeatInterval = 0.2, onClick = lifetime() | - [this] { + [this] { m_clicked++; bindings->notify(&m_clicked); }, diff --git a/examples/showcase/src/Editors.cpp b/examples/showcase/src/Editors.cpp index b96844b8..ad97376f 100644 --- a/examples/showcase/src/Editors.cpp +++ b/examples/showcase/src/Editors.cpp @@ -162,6 +162,7 @@ Rc ShowcaseEditors::build(Rc notifications, Value g textVerticalAlign = TextAlign::Start, width = auto_, enabled = globalEnabled, + fontFamily = "GoNoto", }, rcnew Text{ "PasswordEditor (widgets/TextEditor.hpp)", classes = { "section-header" } }, diff --git a/examples/showcase/src/Typography.cpp b/examples/showcase/src/Typography.cpp index 8a95aef3..c97bd069 100644 --- a/examples/showcase/src/Typography.cpp +++ b/examples/showcase/src/Typography.cpp @@ -129,7 +129,7 @@ Rc emojiWidget(std::u32string str) { textAlign = TextAlign::Center, textVerticalAlign = TextAlign::Center, onClick = staticLifetime | - [str] { + [str] { std::string text; for (char32_t ch : str) { if (ch > U'\uFFFF') @@ -180,7 +180,7 @@ static Builder iconsBuilder() { fontSize = iconFontSize, dimensions = { 36, 36 }, onClick = staticLifetime | - [ch] { + [ch] { Clipboard::setText(fmt::format("\\u{:04X}", uint32_t(ch))); }, }); @@ -237,9 +237,12 @@ Rc ShowcaseTypography::build(Rc notifications, Value& features) -> OpenTypeFeatureFlags { + return { features[0], features[1], features[2], features[3] }; + }), letterSpacing = Value{ &m_letterSpacing }, wordSpacing = Value{ &m_wordSpacing }, textDecoration = Value{ &m_textDecoration }, diff --git a/examples/showcase/src/Typography.hpp b/examples/showcase/src/Typography.hpp index 6faffad8..687dfd84 100644 --- a/examples/showcase/src/Typography.hpp +++ b/examples/showcase/src/Typography.hpp @@ -29,12 +29,12 @@ class ShowcaseTypography : public BindableObject build(Rc notifications, Value globalEnabled); private: - OpenTypeFeatureFlags m_fontFeatures{ + std::array m_fontFeatures{ { { OpenTypeFeature::salt, false }, { OpenTypeFeature::liga, true }, { OpenTypeFeature::onum, false }, { OpenTypeFeature::kern, true }, - }; + } }; float m_letterSpacing = 0.f; float m_wordSpacing = 0.f; TextDecoration m_textDecoration = TextDecoration::None; diff --git a/examples/showcase/src/showcase.cpp b/examples/showcase/src/showcase.cpp index 800bafa5..1fdc053a 100644 --- a/examples/showcase/src/showcase.cpp +++ b/examples/showcase/src/showcase.cpp @@ -24,6 +24,9 @@ int briskMain() { using namespace Brisk; + if (!fonts->addFontFromResource("GoNotoKurrent-Regular.ttf", "GoNoto")) + return 1; + GuiApplication application; return application.run(createComponent()); } diff --git a/include/brisk/core/BasicTypes.hpp b/include/brisk/core/BasicTypes.hpp index 29a8e514..fda32fab 100644 --- a/include/brisk/core/BasicTypes.hpp +++ b/include/brisk/core/BasicTypes.hpp @@ -698,7 +698,7 @@ template Range(T, T) -> Range; template -using InclusiveRange = Range; +using InclusiveRange = Range; template constexpr inline bool isBitFlags = false; diff --git a/include/brisk/core/Encoding.hpp b/include/brisk/core/Encoding.hpp index a60f1d89..ea1dcb67 100644 --- a/include/brisk/core/Encoding.hpp +++ b/include/brisk/core/Encoding.hpp @@ -48,9 +48,9 @@ enum class UtfPolicy { constexpr inline char32_t replacementChar = U'\U0000FFFD'; /** - * @brief The replacement character in char form. + * @brief The replacement character in char32_t form. */ -#define REPLACEMENT_CHAR '\U0000FFFD' +#define REPLACEMENT_CHAR U'\U0000FFFD' /** * @brief The replacement character in string literal form. @@ -481,7 +481,10 @@ inline std::u32string utf32Transform(U32StringView text, function_ref= 0x80000000) write nothing. + * * @param text Pointer to the current position in the text. * @param end Pointer to the end of the text. * @param ch The UTF codepoint to write. @@ -530,13 +548,21 @@ void utfWrite(char*& text, char* end, char32_t ch); * * @param text Pointer to the current position in the text. * @param end Pointer to the end of the text. - * @param ch The UTF codepoint to write. + * @param ch The UTF codepoint to write. For surrogate values no + * check is performed (documented precondition): a value in + * 0xD800..0xDFFF is written verbatim as a single unit, so callers + * must only pass valid scalar values. Out-of-range values + * (> U+10FFFF) and error sentinels are silently skipped. */ void utfWrite(char16_t*& text, char16_t* end, char32_t ch); /** * @brief Writes a UTF codepoint to a UTF-32 text range. * + * Documented preconditions (not validated, for performance): + * - the caller must guarantee `text < end` (exactly one unit is written); + * - `ch` is written verbatim, no range/surrogate checking is performed. + * * @param text Pointer to the current position in the text. * @param end Pointer to the end of the text. * @param ch The UTF codepoint to write. @@ -557,9 +583,9 @@ void utfWrite(wchar_t*& text, wchar_t* end, char32_t ch); */ struct Utf8Character { /** - * @brief Buffer to store the UTF-8 encoded character. + * @brief Buffer to store the UTF-8 encoded character (null-terminated). */ - char buf[4]; + char buf[5]{}; /** * @brief The length of the UTF-8 encoded character in bytes (1-4). @@ -572,8 +598,9 @@ struct Utf8Character { */ explicit Utf8Character(char32_t codepoint) { char* data = buf; - utfWrite(data, buf + std::size(buf), codepoint); - len = static_cast(data - buf); + utfWrite(data, buf + 4, codepoint); + len = static_cast(data - buf); + *data = '\0'; } /** @@ -588,6 +615,10 @@ struct Utf8Character { /** * @brief Struct representing a UTF iterator for iterating over UTF text. * + * @note On invalid UTF sequences, the iterator advances byte-by-byte (or code-unit by code-unit) + * yielding error sentinels (such as `UtfInvalid`). For truncated sequences at the end of input, + * `utfRead` consumes up to `end`, ending iteration on the subsequent increment. + * * @tparam InChar The character type of the input text. */ template @@ -618,7 +649,8 @@ struct UtfIterator { iterator& operator++() noexcept { it = next; - ch = utfRead(next, end); + if (it != end) + ch = utfRead(next, end); return *this; } @@ -723,10 +755,15 @@ constexpr inline bool isBitFlags = true; /** * @brief Normalizes UTF text according to the specified normalization type. * + * @note The @p policy parameter is currently ignored; the input is always + * pre-cleaned with the default policy (invalid sequences are replaced + * with U+FFFD) before normalization. + * * @param text A string view of the input text. * @param normalization The normalization type to apply. - * @param policy The policy to handle invalid characters. - * @return A normalized string. + * @param policy The policy to handle invalid characters (currently ignored). + * @return A normalized string, or an empty string if normalization failed + * (e.g. when normalization contains both Compose and Decompose). */ template std::basic_string utfNormalize(std::basic_string_view text, UtfNormalization normalization, @@ -771,9 +808,9 @@ inline U32String utf32Normalize(U32StringView text, UtfNormalization normalizati return utfNormalize(text, normalization, policy); } -} // namespace Brisk - -namespace std { +// NOTE: defined in namespace Brisk (adding declarations to namespace std is +// undefined behavior per [namespace.std]); they are still found via ADL +// because Json is a Brisk type. /** * @brief Serializes a UTF-32 string to JSON. @@ -782,7 +819,7 @@ namespace std { * @param s The UTF-32 string to serialize. * @return True if serialization was successful, otherwise false. */ -bool toJson(Brisk::Json& j, const std::u32string& s); +bool toJson(Json& j, const std::u32string& s); /** * @brief Serializes a UTF-16 string to JSON. @@ -791,7 +828,7 @@ bool toJson(Brisk::Json& j, const std::u32string& s); * @param s The UTF-16 string to serialize. * @return True if serialization was successful, otherwise false. */ -bool toJson(Brisk::Json& j, const std::u16string& s); +bool toJson(Json& j, const std::u16string& s); /** * @brief Serializes a wide string to JSON. @@ -800,7 +837,7 @@ bool toJson(Brisk::Json& j, const std::u16string& s); * @param s The wide string to serialize. * @return True if serialization was successful, otherwise false. */ -bool toJson(Brisk::Json& j, const std::wstring& s); +bool toJson(Json& j, const std::wstring& s); /** * @brief Deserializes a UTF-32 string from JSON. @@ -809,7 +846,7 @@ bool toJson(Brisk::Json& j, const std::wstring& s); * @param s The UTF-32 string to deserialize into. * @return True if deserialization was successful, otherwise false. */ -bool fromJson(const Brisk::Json& j, std::u32string& s); +bool fromJson(const Json& j, std::u32string& s); /** * @brief Deserializes a UTF-16 string from JSON. @@ -818,7 +855,7 @@ bool fromJson(const Brisk::Json& j, std::u32string& s); * @param s The UTF-16 string to deserialize into. * @return True if deserialization was successful, otherwise false. */ -bool fromJson(const Brisk::Json& j, std::u16string& s); +bool fromJson(const Json& j, std::u16string& s); /** * @brief Deserializes a wide string from JSON. @@ -827,6 +864,6 @@ bool fromJson(const Brisk::Json& j, std::u16string& s); * @param s The wide string to deserialize into. * @return True if deserialization was successful, otherwise false. */ -bool fromJson(const Brisk::Json& j, std::wstring& s); +bool fromJson(const Json& j, std::wstring& s); -} // namespace std +} // namespace Brisk diff --git a/include/brisk/core/Resources.hpp b/include/brisk/core/Resources.hpp index b5a131af..e4f2ab68 100644 --- a/include/brisk/core/Resources.hpp +++ b/include/brisk/core/Resources.hpp @@ -120,9 +120,12 @@ class Resources { */ static const Bytes& loadCached(std::string name, bool emptyOk = false) { static std::map cache; - auto data = load(name, emptyOk); - auto it = cache.insert_or_assign(std::move(name), std::move(data)); - return it.first->second; + if (auto it = cache.find(name); it != cache.end()) { + return it->second; + } + auto data = load(name, emptyOk); + auto [it, inserted] = cache.emplace(std::move(name), std::move(data)); + return it->second; } /** diff --git a/include/brisk/core/Simd.hpp b/include/brisk/core/Simd.hpp index 783c0daa..5889afb7 100644 --- a/include/brisk/core/Simd.hpp +++ b/include/brisk/core/Simd.hpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include "Math.hpp" #include "BasicTypes.hpp" @@ -279,7 +281,7 @@ constexpr auto maskToBits(SimdMask mask) { ReturnType result = 0; for (size_t bit = 0; bit < N; ++bit) { if (mask[bit]) - result |= 1 << bit; + result |= ReturnType(1) << bit; // avoid signed-shift UB for bit == 31 } return result; } @@ -980,6 +982,8 @@ template constexpr T constexprAbs(T x) { if (std::is_constant_evaluated()) return x < T(0) ? -x : x; + else if constexpr (std::is_unsigned_v) + return x; // std::abs has no overload for unsigned types else return std::abs(x); } @@ -994,11 +998,44 @@ constexpr T constexprAbs(T x) { */ template constexpr T constexprCopysign(T x, T s) { - if (std::is_constant_evaluated()) - return s < 0 ? -constexprAbs(x) : constexprAbs(x); - else + if (std::is_constant_evaluated()) { + // Sign-bit based implementation: handles -0.0 and NaN signs correctly. + static_assert(sizeof(T) == 4 || sizeof(T) == 8); + using Bits = std::conditional_t; + constexpr Bits signBit = Bits(1) << (sizeof(T) * 8 - 1); + Bits xb = std::bit_cast(x); + const Bits sb = std::bit_cast(s); + xb = (xb & ~signBit) | (sb & signBit); + return std::bit_cast(xb); + } else return std::copysign(x, s); } + +/** + * @brief Truncates a floating-point value toward zero, optimized for constant evaluation. + */ +template +constexpr T constexprTrunc(T x) { + if (!std::is_constant_evaluated()) + return std::trunc(x); + if (x >= T(0)) { + if (x < T(9223372036854775807.0)) + return T(static_cast(x)); + } else { + if (x > T(-9223372036854775808.0)) + return -T(static_cast(-x)); + } + return x; // magnitude too large to be fractional +} + +/** + * @brief Rounds a floating-point value to the nearest integer (half away from zero), + * optimized for constant evaluation. + */ +template +constexpr T constexprRound(T x) { + return constexprTrunc(x + constexprCopysign(T(0.5), x)); +} } // namespace Internal /** @@ -1366,7 +1403,11 @@ constexpr Simd rescale(Simd value) using Tcommon = std::common_type_t; Simd x = static_cast>(value) * Tcommon(Mout) / Tcommon(Min); if constexpr (!std::is_floating_point_v) { - x += Tcommon(0.5); + // Round to nearest (half away from zero) instead of trunc(x + 0.5), + // which rounded half toward +infinity and was wrong for negative values. + for (size_t i = 0; i < N; i++) { + x.m_data[i] = Internal::constexprRound(x.m_data[i]); + } x = clamp(x, Simd(std::numeric_limits::min()), Simd(std::numeric_limits::max())); } @@ -1391,7 +1432,8 @@ constexpr Simd rescale(Simd value) requires(Mout != Min && !(std::is_floating_point::value || std::is_floating_point::value)) { using Tcommon = - findIntegralType::min() * Mout, std::numeric_limits::max() * Mout>; + findIntegralType::min()) * int64_t(Mout), + int64_t(std::numeric_limits::max()) * int64_t(Mout)>; Simd x = static_cast>(value); if constexpr (Tcommon(std::max(Mout, Min)) % Tcommon(std::min(Mout, Min)) == 0) { constexpr Tcommon scale = std::max(Mout, Min) / std::min(Mout, Min); @@ -1401,7 +1443,10 @@ constexpr Simd rescale(Simd value) x = (x + scale / 2) / scale; } } else { - x = (x * Tcommon(Mout) + Tcommon(Min) / 2) / Tcommon(Min); + // Round-to-nearest: add half a step in the direction of the divisor's sign + // (Tcommon(Min) / 2 truncates toward zero, so it cannot be used directly for negative Min). + constexpr Tcommon halfStep = Tcommon(Min) > 0 ? Tcommon(Min) / 2 : -Tcommon(Min) / 2; + x = (x * Tcommon(Mout) + halfStep) / Tcommon(Min); } if constexpr (Internal::fitsIntType(std::numeric_limits::max())) { x = min(x, Simd(std::numeric_limits::max())); @@ -1463,6 +1508,8 @@ constexpr uint16_t byteswap(uint16_t x) { return __builtin_bswap16(x); ///< Optimized GCC/Clang intrinsic for byte-swapping. #elif defined _MSC_VER return std::rotr(x, 8); ///< MSVC implementation using rotate-right by 8 bits. +#else + return uint16_t((x << 8) | (x >> 8)); ///< Portable fallback. #endif } @@ -1492,6 +1539,8 @@ constexpr uint32_t byteswap(uint32_t x) { // Optimized runtime byte-swap using MSVC intrinsic. return _byteswap_ulong(x); } +#else + return (x << 24) | ((x & 0x0000FF00u) << 8) | ((x & 0x00FF0000u) >> 8) | (x >> 24); #endif } @@ -1521,6 +1570,8 @@ constexpr uint64_t byteswap(uint64_t x) { // Optimized runtime byte-swap using MSVC intrinsic. return _byteswap_uint64(x); } +#else + return (uint64_t(byteswap(uint32_t(x))) << 32) | uint64_t(byteswap(uint32_t(x >> 32))); #endif } diff --git a/include/brisk/core/Utilities.hpp b/include/brisk/core/Utilities.hpp index 690a06f2..90af7b50 100644 --- a/include/brisk/core/Utilities.hpp +++ b/include/brisk/core/Utilities.hpp @@ -247,7 +247,7 @@ struct CacheWithInvalidation { /// Pointer to the instance of the class that holds the member function. Class* self{}; /// The key associated with the cached value. - Key m_key{}; + mutable Key m_key{}; /// Optionally holds the cached value. mutable std::optional m_value; @@ -296,7 +296,7 @@ struct CacheWithInvalidation { * @param key The new key to check. * @return True if the cache was invalidated, otherwise false. */ - bool invalidate(const Key& key, bool force = false) { + bool invalidate(const Key& key, bool force = false) const { if (force || m_key != key) { m_key = key; m_value = std::nullopt; diff --git a/include/brisk/core/internal/COMInit.hpp b/include/brisk/core/internal/COMInit.hpp index 38f17991..d292275e 100644 --- a/include/brisk/core/internal/COMInit.hpp +++ b/include/brisk/core/internal/COMInit.hpp @@ -48,6 +48,11 @@ struct COMInitializer { */ ~COMInitializer(); + COMInitializer(const COMInitializer&) = delete; + COMInitializer& operator=(const COMInitializer&) = delete; + COMInitializer(COMInitializer&&) = delete; + COMInitializer& operator=(COMInitializer&&) = delete; + /// The result of the COM initialization. uint32_t result; diff --git a/include/brisk/core/internal/InlineVector.hpp b/include/brisk/core/internal/InlineVector.hpp index 66ce7bdc..3f608644 100644 --- a/include/brisk/core/internal/InlineVector.hpp +++ b/include/brisk/core/internal/InlineVector.hpp @@ -24,29 +24,10 @@ #include #include #include "Throw.hpp" +#include "lexicographical_compare_three_way.hpp" namespace Brisk { -namespace Internal { - -#if defined _LIBCPP_VERSION && _LIBCPP_VERSION < 170000 -template -constexpr auto lexicographical_compare_three_way(It1 f1, It1 l1, It2 f2, It2 l2, Cmp cmp) - -> decltype(cmp(*f1, *f2)) { - - bool empty1 = (f1 == l1); - bool empty2 = (f2 == l2); - for (; !empty1 && !empty2; empty1 = (++f1 == l1), empty2 = (++f2 == l2)) - if (auto c = cmp(*f1, *f2); c != 0) - return c; - - return !empty1 ? std::strong_ordering::greater - : !empty2 ? std::strong_ordering::less - : std::strong_ordering::equal; -} -#endif -} // namespace Internal - /** * @brief A resizeable vector-like container with fixed capacity. * @@ -76,6 +57,10 @@ struct inline_vector { static_assert(std::is_trivially_move_constructible_v); static_assert(std::is_trivially_destructible_v); + constexpr static size_t capacity() noexcept { + return N; + } + using size_type = size_t; using stored_size_type = std::conditional_t<(N >= UINT16_MAX), uint32_t, std::conditional_t<(N >= UINT8_MAX), uint16_t, uint8_t>>; @@ -212,6 +197,13 @@ struct inline_vector { m_values[m_size++] = value; } + constexpr void pop_back() noexcept { + if (m_size == 0) { + throw_range_error("inline_vector: vector is empty"); + } + --m_size; + } + constexpr bool operator==(const inline_vector& other) const noexcept(noexcept(std::declval() == std::declval())) { return std::equal(begin(), end(), other.begin(), other.end()); @@ -219,13 +211,8 @@ struct inline_vector { constexpr std::strong_ordering operator<=>(const inline_vector& other) const noexcept(noexcept(std::declval() <=> std::declval())) { -#if defined _LIBCPP_VERSION && _LIBCPP_VERSION < 170000 - return Internal::lexicographical_compare_three_way(begin(), end(), other.begin(), other.end(), - std::compare_three_way{}); -#else - return std::lexicographical_compare_three_way(begin(), end(), other.begin(), other.end(), - std::compare_three_way{}); -#endif + return BRISK_LEXICOGRAPHICAL_COMPARE_THREE_WAY(begin(), end(), other.begin(), other.end(), + std::compare_three_way{}); } T m_values[N]{}; diff --git a/include/brisk/core/internal/SmallVector.hpp b/include/brisk/core/internal/SmallVector.hpp index 4800efbd..283ea420 100644 --- a/include/brisk/core/internal/SmallVector.hpp +++ b/include/brisk/core/internal/SmallVector.hpp @@ -20,6 +20,20 @@ */ #pragma once #include "llvm/SmallVector.h" +#include "lexicographical_compare_three_way.hpp" + +namespace llvm { + +template + requires std::three_way_comparable +constexpr auto operator<=>(const SmallVector& lhs, const SmallVector& rhs) noexcept( + noexcept(std::compare_three_way{}(std::declval(), std::declval()))) + -> std::compare_three_way_result_t { + return BRISK_LEXICOGRAPHICAL_COMPARE_THREE_WAY(lhs.begin(), lhs.end(), rhs.begin(), rhs.end(), + std::compare_three_way{}); +} + +} // namespace llvm namespace Brisk { diff --git a/include/brisk/core/internal/lexicographical_compare_three_way.hpp b/include/brisk/core/internal/lexicographical_compare_three_way.hpp new file mode 100644 index 00000000..b30d27be --- /dev/null +++ b/include/brisk/core/internal/lexicographical_compare_three_way.hpp @@ -0,0 +1,55 @@ +/* + * Brisk + * + * Cross-platform application framework + * -------------------------------------------------------------- + * + * Copyright (C) 2025 Brisk Developers + * + * This file is part of the Brisk library. + * + * Brisk is dual-licensed under the GNU General Public License, version 2 (GPL-2.0+), + * and a commercial license. You may use, modify, and distribute this software under + * the terms of the GPL-2.0+ license if you comply with its conditions. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + * + * If you do not wish to be bound by the GPL-2.0+ license, you must purchase a commercial + * license. For commercial licensing options, please visit: https://brisklib.com + */ +#pragma once + +#include +#include + +namespace Brisk { + +namespace Internal { + +#if defined _LIBCPP_VERSION && _LIBCPP_VERSION < 170000 +template +constexpr auto lexicographical_compare_three_way(It1 f1, It1 l1, It2 f2, It2 l2, Cmp cmp) + -> std::common_comparison_category_t { + using result_type = std::common_comparison_category_t; + + while (f1 != l1 && f2 != l2) { + if (auto result = cmp(*f1, *f2); result != 0) + return result; + ++f1; + ++f2; + } + + if (f1 == l1 && f2 == l2) + return result_type::equivalent; + return f1 == l1 ? result_type::less : result_type::greater; +} + +#define BRISK_LEXICOGRAPHICAL_COMPARE_THREE_WAY(...) \ + ::Brisk::Internal::lexicographical_compare_three_way(__VA_ARGS__) +#else +#define BRISK_LEXICOGRAPHICAL_COMPARE_THREE_WAY(...) std::lexicographical_compare_three_way(__VA_ARGS__) +#endif +} // namespace Internal + +} // namespace Brisk diff --git a/include/brisk/graphics/Canvas.hpp b/include/brisk/graphics/Canvas.hpp index 04634ee3..eb0f22b8 100644 --- a/include/brisk/graphics/Canvas.hpp +++ b/include/brisk/graphics/Canvas.hpp @@ -615,55 +615,27 @@ class Canvas { void fillText(TextWithOptions text, RectangleF position, PointF alignment = PointF{ 0.f, 0.f }); /** - * @brief Fills pre-rendered text at the specified position. + * @brief Fills a text layout at the specified position. * - * This function renders the provided PreparedText at the given position using the canvas's - * current fill settings. - * - * @param position The PointF specifying the top-left position of the text. - * @param text The PreparedText struct containing the pre-rendered text to fill. + * The text layout is immutable and may be retained independently of the + * FontManager that created it. Glyph rasterization and renderer caching remain + * private to the graphics/text-layout integration. */ - void fillText(PointF position, const PreparedText& text); + void fillText(PointF position, const TextLayout& text); - /** - * @brief Fills pre-rendered text with specified alignment. - * - * This function renders the provided PreparedText at the given position, aligned according - * to the specified alignment point, using the canvas's current fill settings. - * - * @param position The PointF specifying the reference position for the text. - * @param alignment The PointF specifying the alignment offset relative to the position. - * @param text The PreparedText struct containing the pre-rendered text to fill. - */ - void fillText(PointF position, PointF alignment, const PreparedText& text); + /** @brief Fills a text layout aligned relative to the specified position. */ + void fillText(PointF position, PointF alignment, const TextLayout& text); /** - * @brief Fills the selection rectangle(s) for pre-rendered text. + * @brief Fills the selection in a text layout. * - * This function renders the selection rectangle(s) for the specified range within the - * PreparedText at the given position, using the canvas's current fill settings. It does - * not render the text itself, only the selection background. - * - * @param position The PointF specifying the top-left position of the text. - * @param text The PreparedText struct containing the pre-rendered text. - * @param selection The Range specifying the start and end indices of the selection. + * Selection offsets are text character/codepoint offsets, with an + * exclusive maximum, matching the legacy Canvas selection API. */ - void fillTextSelection(PointF position, const PreparedText& text, Range selection); + void fillTextSelection(PointF position, const TextLayout& text, Range selection); - /** - * @brief Fills the selection rectangle(s) for pre-rendered text with specified alignment. - * - * This function renders the selection rectangle(s) for the specified range within the - * PreparedText at the given position, aligned according to the specified alignment point, - * using the canvas's current fill settings. It does not render the text itself, only the - * selection background. - * - * @param position The PointF specifying the reference position for the text. - * @param alignment The PointF specifying the alignment offset relative to the position. - * @param text The PreparedText struct containing the pre-rendered text. - * @param selection The Range specifying the start and end indices of the selection. - */ - void fillTextSelection(PointF position, PointF alignment, const PreparedText& text, + /** @brief Fills a text selection aligned relative to the specified position. */ + void fillTextSelection(PointF position, PointF alignment, const TextLayout& text, Range selection); /** diff --git a/include/brisk/graphics/Fonts.hpp b/include/brisk/graphics/Fonts.hpp index ae943507..04e19c51 100644 --- a/include/brisk/graphics/Fonts.hpp +++ b/include/brisk/graphics/Fonts.hpp @@ -31,6 +31,11 @@ #include #include "internal/Sprites.hpp" #include "I18n.hpp" +#include +#include +#include +#include +#include namespace Brisk { @@ -44,8 +49,6 @@ class EFreeType : public ELogic { using ELogic::ELogic; }; -using GlyphId = uint32_t; - enum class TextOptions : uint32_t { Default = 0, SingleLine = 1, @@ -130,6 +133,13 @@ template <> constexpr inline bool isBitFlags = true; class FontManager; +class ShapedText; +class TextLayout; +struct TextSelectionRect; + +namespace Internal { +struct TextEngineState; +} // namespace Internal /** * @struct FontMetrics @@ -194,663 +204,263 @@ struct FontMetrics { }; }; -struct GlyphRun; - -namespace Internal { - -struct FontFace; -struct GlyphData; -struct TextRun; - -/** - * @brief Represents a segment of text with uniform properties such as direction and font face. - */ -struct TextRun { - /** - * @brief The direction of the text in this run (e.g., left-to-right or right-to-left). - */ - TextDirection direction; - - /** - * @brief The position of the first character of the text run. - */ - uint32_t begin; - - /** - * @brief The position just beyond the last character of the text run. - */ - uint32_t end; - - /** - * @brief The visual order of the text run. - * - * This indicates the position of the run when rendered visually. - */ - uint32_t visualOrder; - - uint32_t fontIndex; - - /** - * @brief Pointer to the font face associated with the text run. - */ - FontFace* face; - - /** - * @brief Equality comparison operator for `TextRun`. - */ - bool operator==(const TextRun&) const noexcept = default; - - inline static const std::tuple reflection = { - ReflectionField{ "direction", &TextRun::direction }, - ReflectionField{ "begin", &TextRun::begin }, - ReflectionField{ "end", &TextRun::end }, - ReflectionField{ "visualOrder", &TextRun::visualOrder }, - }; +/** @brief Horizontal alignment used when producing a `TextLayout`. */ +enum class TextLayoutAlignment : uint8_t { + Start, ///< Align to the start edge of each paragraph, respecting its direction. + End, ///< Align to the end edge of each paragraph, respecting its direction. + Left, ///< Align to the physical left edge. + Right, ///< Align to the physical right edge. + Center, ///< Center each line in the available layout width. }; /** - * @enum GlyphFlags - * @brief Flags used to define various properties of glyphs. - * - * This enum class defines a set of flags used to represent - * different characteristics of glyphs, which are typically - * used in text rendering or processing. + * @brief Options controlling conversion of shaped text into a `TextLayout`. */ -enum class GlyphFlags : uint8_t { - /** - * @brief No special properties. - * - * The glyph has no special attributes or flags. - */ - None = 0, - - /** - * @brief Glyph can be safely broken across lines. - * - * Indicates that this glyph can be safely broken - * when wrapping text across multiple lines. - */ - SafeToBreak = 1, - - /** - * @brief Glyph is at a line break position. - * - * Marks this glyph as occurring at a line break - * (e.g., the glyph is at the end of a line or paragraph). - */ - AtLineBreak = 2, - - /** - * @brief Glyph represents a control character. - * - * This flag indicates that the glyph is a control character, - * such as a non-printing character used for text formatting or control. - */ - IsControl = 4, - - /** - * @brief Glyph is printable. - * - * Indicates that this glyph is part of the visible text - * and can be printed or displayed on screen. - */ - IsPrintable = 8, - - /** - * @brief Glyph is compacted whitespace. - * - * Denotes that the glyph represents whitespace at a line break - * which doesn't extend the visual line bounds. This is commonly - * used to indicate collapsed or compacted whitespace characters. - */ - IsCompactedWhitespace = 16, +struct TextLayoutOptions { + /// Maximum line width in pixels. Finite positive values enable wrapping; zero does not impose a + /// width limit. The resulting coordinates are intrinsic and can be translated by the caller. + float maxLineWidth = HUGE_VALF; + /// Additional indentation applied to the first line of each paragraph, in pixels. + float firstLineIndent = 0.f; + /// Absolute tab-stop interval in pixels. Zero uses the interval configured by the shaped text. + float tabWidth = 0.f; + /// Horizontal alignment of each laid-out line. + TextLayoutAlignment alignment = TextLayoutAlignment::Start; + /// If true, permits line breaks at grapheme boundaries where ordinary word wrapping would not. + bool allowBreakAnywhere = false; }; -using FTFixed = int32_t; - -/** - * @brief Represents an individual glyph with its properties and positioning information. - */ -struct Glyph { - /** - * @brief The glyph ID, identifying the specific glyph in the font. - */ - uint32_t glyph = UINT32_MAX; - - /** - * @brief The Unicode codepoint represented by this glyph. - */ - char32_t codepoint = UINT32_MAX; - - /** - * @brief Position of the glyph relative to its parent context. - */ - PointF pos = { -1.f, -1.f }; - - /** - * @brief The position of the left caret for the glyph. - */ - float left_caret = -1.f; - - /** - * @brief The position of the right caret for the glyph. - */ - float right_caret = -1.f; - - /** - * @brief The index of the first character in the cluster associated with the glyph. - */ - uint32_t begin_char = UINT32_MAX; - - /** - * @brief The index of the first character after the cluster associated with the glyph. - * This marks the position immediately following the last character in the cluster. - */ - uint32_t end_char = UINT32_MAX; - - Range charRange() const { - return { begin_char, end_char }; - } - - InclusiveRange caretRange() const { - return { left_caret, right_caret }; - } - - /** - * @brief The text direction for this glyph (e.g., left-to-right or right-to-left). - */ - TextDirection dir = TextDirection::LTR; - - /** - * @brief Flags providing additional properties or states of the glyph. - */ - GlyphFlags flags = GlyphFlags::None; - - /** - * @brief Computes the caret position for the glyph based on the text direction. - * - * @param inverse If true, calculates the caret in the opposite direction. - * @return float The computed caret position. - * - * @note This is an internal method and should not be used directly. - */ - float caretForDirection(bool inverse) const; - - /** - * @brief Loads and renders the glyph into `SpriteResource`. - * - * @param run The glyph run containing this glyph. - * @return std::optional The loaded glyph data if available, or an empty value otherwise. - * - * @note This is an internal method and should not be used directly. - */ - std::optional load(const GlyphRun& run) const; +/** @brief Selects which visual side owns a caret at an ambiguous boundary. */ +enum class CaretAffinity : uint8_t { + Upstream, ///< Associate the caret with the preceding visual position. + Downstream, ///< Associate the caret with the following visual position. }; -/** - * @brief Contains detailed data about a single glyph, including its metrics and rendered sprite. - */ -struct GlyphData { - /** - * @brief The size of the glyph in its rendered form. - */ - Size size; - - /** - * @brief A reference-counted resource pointing to the sprite used to render the glyph. - */ - Rc sprite; - - /** - * @brief The horizontal offset from the glyph's origin to the start of its shape. - * - * Known as the "left bearing," this value determines the space between the glyph's origin and - * the leftmost edge of its bounding box. - */ - float offset_x; - - /** - * @brief The vertical offset from the glyph's origin to the top of its shape. - * - * Known as the "top bearing," this value is positive for upwards Y-coordinates and determines - * the space between the glyph's baseline and the topmost edge of its bounding box. - */ - int offset_y; - - /** - * @brief The horizontal distance to advance to the next glyph's origin. - * - * This is the space the glyph occupies horizontally, including the glyph itself and any trailing - * whitespace. - */ - float advance_x; +/** @brief A caret location expressed as a grapheme boundary and visual affinity. */ +struct CaretIndex { + uint32_t grapheme = 0; ///< Grapheme boundary index. + CaretAffinity affinity = CaretAffinity::Downstream; ///< Visual side at the boundary. }; -using GlyphList = SmallVector; - -} // namespace Internal +/** @brief A caret location expressed in layout coordinates. */ +struct CaretPosition { + float x = 0.f; ///< Horizontal caret position in layout coordinates. + uint32_t line = 0; ///< Zero-based line index. + uint8_t bidiLevel = 0; ///< Bidirectional embedding level at the caret. +}; -template <> -constexpr inline bool isBitFlags = true; +/** @brief One rectangle segment belonging to a text selection. */ +struct TextSelectionRect { + uint32_t line = 0; ///< Zero-based line index. + float x0 = 0.f; ///< Start of the selected segment in layout coordinates. + float x1 = 0.f; ///< End of the selected segment in layout coordinates. +}; /** - * @brief Specifies the types of bounds that can be calculated for a glyph run. + * @brief Character and grapheme range for one paragraph in `ShapedText`. */ -enum class GlyphRunBounds { - /** - * @brief Bounds that consider all glyphs in the run, including whitespace. - */ - Text, - - /** - * @brief Bounds that exclude whitespace at line breaks. - */ - Alignment, +struct ShapedParagraph { + Range characterRange{ 0, 0 }; ///< Half-open character/codepoint range. + Range graphemeRange{ 0, 0 }; ///< Half-open grapheme range. + TextDirection direction = TextDirection::LTR; ///< Base direction of the paragraph. +}; - /** - * @brief Bounds that consider only printable glyphs in the run. - */ - Printable, +/** @brief Describes why a line ends. */ +enum class TextLayoutLineEnd : uint8_t { + SoftWrap, ///< The line ended because of the available width. + MandatoryBreak, ///< The line ended at an explicit line-break character. + ParagraphEnd, ///< The line ended at the end of a paragraph. }; -struct AscenderDescender { - float ascender; // always positive - float descender; // always positive +/** @brief Metrics and source ranges for one line in a `TextLayout`. */ +struct TextLine { + Range characterRange{ 0, 0 }; ///< Half-open character/codepoint range. + Range graphemeRange{ 0, 0 }; ///< Half-open grapheme range. + TextDirection direction = TextDirection::LTR; ///< Base direction of the line. + TextLayoutLineEnd end = TextLayoutLineEnd::ParagraphEnd; ///< Reason the line ends. + float baseline = 0.f; ///< Baseline y-coordinate. + float ascender = 0.f; ///< Distance above the baseline. + float descender = 0.f; ///< Distance below the baseline; normally negative. + float leading = 0.f; ///< Extra line spacing distributed around the line. + float width = 0.f; ///< Width including trailing whitespace. + float trimmedWidth = 0.f; ///< Width excluding trailing whitespace. +}; - float height() const noexcept { - return ascender + descender; - } +class TextLayout; - friend AscenderDescender max(AscenderDescender a, AscenderDescender b) noexcept { - return { std::max(a.ascender, b.ascender), std::max(a.descender, b.descender) }; - } - - bool operator==(const AscenderDescender&) const noexcept = default; -}; +struct PimplAccessor; /** - * @brief Represents a sequence of glyphs along with their associated properties. + * @brief Immutable, width-independent representation of shaped text. * - * The `GlyphRun` struct encapsulates the properties and operations of a run of glyphs, - * including positioning, font information, and range calculations. + * ShapedText stores the source-to-grapheme mappings and paragraph information + * needed for text editing, cursor movement, selection, and layout creation. + * It has value semantics: copies share the immutable shaped data through a + * `std::shared_ptr`-backed store, while each value can be used independently. + * Use layout() to produce a width-dependent TextLayout for measurement or drawing. */ -struct GlyphRun { - /** - * @brief List of glyphs contained in this run in visual order (left-to-right). - */ - Internal::GlyphList glyphs; - - /** - * @brief Pointer to the font face associated with the glyph run. - */ - Internal::FontFace* face; - - /** - * @brief Font size of the glyph run. - */ - float fontSize = 0; - - float tabWidth = 0; - float lineHeight = 0; - - /** - * @brief Metrics of the font used in the glyph run. - */ - FontMetrics metrics; - - /** - * @brief Text decoration applied to the glyph run (e.g., underline, strikethrough). - */ - TextDecoration decoration = TextDecoration::None; - - /** - * @brief Text direction of the glyph run (left-to-right or right-to-left). - */ - TextDirection direction; - - /** - * @brief Indicates whether the horizontal ranges are valid and up-to-date. - */ - mutable bool rangesValid = false; - - /** - * @brief Horizontal range of all glyphs in the run. - */ - mutable InclusiveRange textHRange; - - /** - * @brief Horizontal range of glyphs, excluding whitespace at line breaks. - */ - mutable InclusiveRange alignmentHRange; - - /** - * @brief Horizontal range of printable glyphs in the run. - */ - mutable InclusiveRange printableHRange; - - /** - * @brief Visual order of the glyph run within the text. - */ - int32_t visualOrder; - - /** - * @brief Vertical offset of the glyph run relative to the text baseline. - */ - float verticalAlign; - - /** - * @brief Position of the left-most point of the glyph run at the text baseline. - */ - PointF position; - - std::optional color; - - InclusiveRange textVRange() const; - - AscenderDescender ascDesc() const; - - float firstCaret() const noexcept; - - float lastCaret() const noexcept; - - int hscale() const noexcept; - bool hasColor() const noexcept; - - /** - * @brief Returns the bounds of the glyph run. - * - * @param boundsType Specifies the type of bounds to compute. - * @return RectangleF The computed bounds of the glyph run. - */ - RectangleF bounds(GlyphRunBounds boundsType) const; - - /** - * @brief Returns the size of the glyph run. - * - * @param boundsType Specifies the type of bounds to consider for size calculation. - * @return SizeF The size of the glyph run. - */ - SizeF size(GlyphRunBounds boundsType) const; - - /** - * @brief Marks the horizontal ranges of the glyph run as invalid. - * - * Call this function whenever glyph modifications require recalculating ranges. - */ - void invalidateRanges(); - - /** - * @brief Updates the horizontal ranges of the glyph run. - * - * Ensures the ranges (e.g., `textHRange`, `alignmentHRange`, `printableHRange`) - * are valid and accurate. - */ - void updateRanges() const; +class ShapedText { +public: + struct Impl; + + /// Constructs an empty shaped-text value. + ShapedText(); + ~ShapedText(); + + /// Copies a shaped-text value and its source mappings. + ShapedText(const ShapedText&); + ShapedText& operator=(const ShapedText&); + /// Transfers a shaped-text value into a new object. + ShapedText(ShapedText&&) noexcept; + ShapedText& operator=(ShapedText&&) noexcept; + + /// @return `true` if no text was shaped, otherwise `false`. + [[nodiscard]] bool empty() const noexcept; + /// @return Number of source Unicode codepoints represented by the text. + [[nodiscard]] uint32_t characterCount() const noexcept; + /// @return Number of graphemes represented by the source text. + [[nodiscard]] uint32_t graphemeCount() const noexcept; + /// @return Source character boundaries for graphemes, including the initial and final boundary. + [[nodiscard]] std::span graphemeBoundaries() const noexcept; + /// @return Number of paragraphs in the shaped text. + [[nodiscard]] size_t paragraphCount() const noexcept; + /// @param index Zero-based paragraph index. + /// @return Paragraph ranges, or an empty value if `index` is out of range. + [[nodiscard]] ShapedParagraph paragraph(size_t index) const noexcept; + + /// @param character Source character/codepoint boundary, clamped to `characterCount()`. + /// @return Grapheme boundary containing the specified character boundary. + [[nodiscard]] uint32_t characterToGrapheme(uint32_t character) const noexcept; + /// @param grapheme Grapheme boundary, clamped to `graphemeCount()`. + /// @return Source character/codepoint boundary corresponding to the grapheme boundary. + [[nodiscard]] uint32_t graphemeToCharacter(uint32_t grapheme) const noexcept; + /// @param grapheme Grapheme index, clamped to the last grapheme. + /// @return Half-open source character range covered by the grapheme. + [[nodiscard]] Range graphemeToCharacters(uint32_t grapheme) const noexcept; + /// @param grapheme Grapheme index to inspect. + /// @return `true` if the grapheme is a paragraph separator rather than paragraph content. + [[nodiscard]] bool isParagraphSeparator(uint32_t grapheme) const noexcept; + /// @param character Source character/codepoint boundary. + /// @param affinity Visual side to associate with the resulting caret. + /// @return Affinity-aware caret index for the boundary. + [[nodiscard]] CaretIndex caretFromCharacter( + uint32_t character, CaretAffinity affinity = CaretAffinity::Downstream) const noexcept; + /// @param caret Grapheme boundary and visual affinity. + /// @return Source character/codepoint boundary represented by the caret. + [[nodiscard]] uint32_t characterFromCaret(CaretIndex caret) const noexcept; + + /// Creates a width-dependent layout using the supplied wrapping and alignment options. + /// @param options Wrapping, indentation, tab-stop, and alignment settings. + /// @return Immutable layout suitable for measurement, interaction, and rendering. + [[nodiscard]] TextLayout layout(const TextLayoutOptions& options = {}) const; - /** - * @brief Returns the widest glyph run that fits within the specified width and removes those glyphs. - * - * @param width The maximum width available for the glyph run. - * @param allowEmpty A boolean flag that determines whether an empty glyph run is allowed as a result. - * @return GlyphRun The widest glyph run that fits within the specified width. - */ - GlyphRun breakAt(float width, bool allowEmpty, bool wrapAnywhere) &; +private: + explicit ShapedText(std::shared_ptr impl); + friend struct PimplAccessor; - /** - * @brief Retrieves the glyph flags associated with the glyph run. - * - * @return Internal::GlyphFlags The flags indicating properties or states of the glyphs. - */ - Internal::GlyphFlags flags() const; + std::shared_ptr m_impl; - /** - * @brief Retrieves the character range covered by the glyph run. - * - * @return Range The range of characters covered by this glyph run. - */ - Range charRange() const; + friend class FontManager; }; -using GlyphRuns = SmallVector; - -struct Font; - /** - * @brief Represents text that has been processed and prepared for rendering or layout. + * @brief Immutable width-dependent layout with measurement and interaction operations. * - * The `PreparedText` struct manages glyph runs, logical and visual orders, grapheme boundaries, - * caret positions, and alignment for efficient text layout and rendering. + * TextLayout contains the lines, metrics, bounds, and source mappings resulting + * from laying out a ShapedText with specific width and alignment options. + * It supports caret positioning, hit-testing, vertical caret movement, and + * selection-rectangle generation in layout coordinates. + * It has value semantics: copies share the immutable layout data through a + * `std::shared_ptr`-backed store and remain safe to retain independently. */ -struct PreparedText { - /** - * @brief The glyph runs associated with the text, stored in logical order. - */ - GlyphRuns runs; - - /** - * @brief The visual order of the glyph runs. - * - * Each entry corresponds to an index in `runs`, and `visualOrder.size()` must equal `runs.size()`. - */ - std::vector visualOrder; - - /** - * @brief Layout options applied during text preparation. - */ - TextOptions options = TextOptions::Default; - - /** - * @brief Caret offsets (grapheme boundaries) within the text. - * - * Each entry indicates the a grapheme boundary, providing a mapping between graphemes and characters. - */ - std::vector graphemeBoundaries; - - /** - * @brief The caret positions for each grapheme boundary. - * - * This vector is populated by `updateCaretData` and contains one entry per grapheme boundary. - */ - std::vector caretPositions; - - /** - * @brief The ranges of horizontal positions for each grapheme. - * - * This vector is populated by `updateCaretData` and contains one entry per grapheme. - */ - std::vector> ranges; - - bool hasCaretData() const noexcept; - - /** - * @brief Represents a line of glyphs, including its range and metrics. - */ - struct GlyphLine { - /** - * @brief Range of runs (sequence of glyphs sharing the same style) in the line. - * - * This range is empty if the line contains no runs. - */ - Range runRange{ UINT32_MAX, 0 }; - - /** - * @brief Range of grapheme boundaries (caret positions) in the line. - * - * This range is always non-empty, even if the line contains no visible content. - */ - Range graphemeRange{ UINT32_MAX, 0 }; - - /** - * @brief The ascender and descender metrics for the line. - * - * Contains the maximum ascender and descender values for the glyphs in the line. - */ - AscenderDescender ascDesc{ 0, 0 }; - - /** - * @brief The baseline position for the line. - * - * Represents the vertical offset between this line's baseline and the baseline of the first line. - */ - float baseline = 0.f; - - /** - * @brief Checks if the line is empty. - * - * A line is considered empty if it contains no runs. - * - * @return `true` if the line is empty, `false` otherwise. - */ - bool empty() const noexcept { - return runRange.empty(); - } - }; - - /** - * @brief A collection of GlyphLine objects, representing multiple lines of text. - */ - std::vector lines; - - /** - * @brief Updates the caret positions and horizontal ranges for graphemes. - * - * This function calculates and fills the `caretPositions` and `ranges` fields based on the current text - * properties. - */ - void updateCaretData(); - - /** - * @brief Maps a point to the nearest grapheme boundary. - * - * Determines the grapheme boundary index corresponding to the provided point in text layout space. - * - * @param pt The point in text layout space. - * @return uint32_t The index of the nearest grapheme boundary. - */ - uint32_t caretToGrapheme(PointF pt) const; - - uint32_t caretToGrapheme(uint32_t line, float x) const; - - /** - * @brief Maps a grapheme boundary index to its caret position. - * - * Calculates the position of the caret corresponding to the given grapheme boundary in text layout space. - * - * @param graphemeIndex The index of the grapheme boundary. - * @return PointF The caret position for the specified grapheme. - */ - PointF graphemeToCaret(uint32_t graphemeIndex) const; - - uint32_t graphemeToLine(uint32_t graphemeIndex) const; - - /** - * @brief Maps a vertical position to the nearest text line. - * - * Determines the line index corresponding to the given vertical position in text layout space. - * - * @param y The vertical position in text layout space. - * @return int32_t The index of the nearest line, or -1 if the position is outside the layout. - */ - int32_t yToLine(float y) const; - - /** - * @brief Retrieves a glyph run in visual order. - * - * @param index The index in visual order to retrieve. - * @return const GlyphRun& The glyph run at the specified visual order index. - */ - const GlyphRun& runVisual(uint32_t index) const; - - /** - * @brief Retrieves a modifiable glyph run in visual order. - * - * @param index The index in visual order to retrieve. - * @return GlyphRun& The modifiable glyph run at the specified visual order index. - */ - GlyphRun& runVisual(uint32_t index); - - /** - * @brief Calculates the bounds of the text based on the specified bounds type. - * - * @param boundsType The type of bounds to calculate (e.g., text, alignment, printable). - * @return RectangleF The calculated bounds of the text. - */ - RectangleF bounds(GlyphRunBounds boundsType = GlyphRunBounds::Alignment) const; - - /** - * @brief Wraps text to fit within the given width, modifying the current object. - * - * Wraps lines of text to fit within the specified `maxWidth`. If `wrapAnywhere` is true, - * the text can break between any graphemes. Otherwise, breaks occur at word boundaries. - * - * @param maxWidth Maximum allowed width for the text. - * @param wrapAnywhere If true, allows breaking between any graphemes; otherwise, breaks at word - * boundaries. - * @return PreparedText The modified object with wrapped lines. - */ - PreparedText wrap(float maxWidth, bool wrapAnywhere = false) &&; - - /** - * @brief Wraps text to fit within the given width, returning a copy. - * - * Wraps lines of text so that they fit within the specified `maxWidth`. If `wrapAnywhere` is true, - * the text can break between any graphemes. Otherwise, breaks occur at word boundaries. - * - * @param maxWidth Maximum allowed width for the text. - * @param wrapAnywhere If true, allows breaking between any graphemes; otherwise, breaks at word - * boundaries. - * @return PreparedText A copy with wrapped lines. - */ - PreparedText wrap(float maxWidth, bool wrapAnywhere = false) const&; - - /** - * @brief Aligns text lines horizontally and vertically. - * - * Adjusts the horizontal offsets of each line based on `alignment_x` and returns a PointF with overall - * horizontal and vertical offsets. Apply the returned offset when painting PreparedText for proper - * vertical alignment. - * - * @param alignment_x Horizontal alignment factor (0: left, 0.5: center, 1: right). - * @param alignment_y Vertical alignment factor (0: top, 0.5: center, 1: bottom). - * @return PointF Offsets for alignment. - */ - [[nodiscard]] PointF alignLines(float alignment_x, float alignment_y = 0.f); - - [[nodiscard]] PointF alignLines(PointF alignment); +class TextLayout { +public: + struct Impl; + /// Constructs an empty text layout. + TextLayout(); + ~TextLayout(); + + /// Copies a text layout and its interaction data. + TextLayout(const TextLayout&); + TextLayout& operator=(const TextLayout&); + /// Transfers a text layout into a new object. + TextLayout(TextLayout&&) noexcept; + TextLayout& operator=(TextLayout&&) noexcept; + + /// @return `true` if the layout contains no lines, otherwise `false`. + [[nodiscard]] bool empty() const noexcept; + /// @return Number of laid-out lines, including empty trailing lines. + [[nodiscard]] size_t lineCount() const noexcept; + /// @param index Zero-based line index. + /// @return Line metrics and source ranges, or an empty value if `index` is out of range. + [[nodiscard]] TextLine line(size_t index) const noexcept; + /// @return Bounds including trailing whitespace and the full line extents. + [[nodiscard]] RectangleF bounds() const noexcept; + /// @return Bounds excluding trailing whitespace while retaining line extents. + [[nodiscard]] RectangleF trimmedBounds() const noexcept; + + /// @param index Grapheme boundary and affinity to locate. + /// @return Caret position in layout coordinates. + [[nodiscard]] CaretPosition caretPosition(CaretIndex index) const noexcept; + /// @param index Grapheme boundary and affinity to locate. + /// @return Zero-based line containing the caret; returns zero for an empty layout. + [[nodiscard]] size_t lineForCaret(CaretIndex index) const noexcept; + /// @param character Source character/codepoint boundary. + /// @param affinity Visual side to associate with the boundary. + /// @return Zero-based line containing the boundary. + [[nodiscard]] size_t lineForCharacter(uint32_t character, + CaretAffinity affinity = CaretAffinity::Downstream) const noexcept; + /// @param line Zero-based line index. + /// @return Downstream-affinity caret at the beginning of the line. + [[nodiscard]] CaretIndex lineBeginning(size_t line) const noexcept; + /// @param line Zero-based line index. + /// @return Upstream-affinity caret at the end of the line. + [[nodiscard]] CaretIndex lineEnd(size_t line) const noexcept; + /// @param index Caret to measure. + /// @param width Width of the caret rectangle in pixels. + /// @return Rectangle spanning the line's ascent, descent, and leading. + [[nodiscard]] RectangleF caretRect(CaretIndex index, float width = 1.f) const noexcept; + /// @param point Layout-coordinate point to inspect. + /// @return Nearest caret, including affinity at bidirectional and wrapped boundaries. + [[nodiscard]] CaretIndex hitTest(PointF point) const noexcept; + /// @param line Zero-based line index. + /// @param x Horizontal layout-coordinate position. + /// @return Nearest caret on the requested line. + [[nodiscard]] CaretIndex hitTestLine(size_t line, float x) const noexcept; + /// @param current Current caret. + /// @param preferredX Horizontal position to preserve while moving. + /// @param direction Negative moves upward; positive moves downward; zero leaves the caret unchanged. + /// @return Caret on the adjacent line nearest to `preferredX`. + [[nodiscard]] CaretIndex moveCaretVertically(CaretIndex current, float preferredX, + int direction) const noexcept; + /// Enumerates selection rectangles for a grapheme range. + /// @param selection Half-open grapheme range. + /// @param onRect Callback invoked once for each line segment. + void selectionRects(Range selection, function_ref onRect) const; + /// Enumerates selection rectangles for a source character/codepoint range. + /// @param selection Half-open character/codepoint range. + /// @param onRect Callback invoked once for each line segment. + void selectionRectsByCharacter(Range selection, + function_ref onRect) const; - /** - * @brief Converts a character index to its corresponding grapheme index. - * - * @param charIndex The character index to convert. - * @return uint32_t The corresponding grapheme index. - */ - uint32_t characterToGrapheme(uint32_t charIndex) const; +private: + friend struct PimplAccessor; + explicit TextLayout(std::shared_ptr impl); - /** - * @brief Converts a grapheme index to its corresponding character index. - * - * @param graphemeIndex The grapheme index to convert. - * @return uint32_t The corresponding character index. - */ - uint32_t graphemeToCharacter(uint32_t graphemeIndex) const; + std::shared_ptr m_impl; - /** - * @brief Retrieves the range of characters corresponding to a grapheme index. - * - * @param graphemeIndex The grapheme index to query. - * @return Range The range of character indices covered by the grapheme. - */ - Range graphemeToCharacters(uint32_t graphemeIndex) const; + friend class ShapedText; + friend class FontManager; }; /** * @brief A collection of OpenType feature flags. */ -using OpenTypeFeatureFlags = inline_vector; +using OpenTypeFeatureFlags = SmallVector; namespace Internal { -inline std::string format_as(const inline_vector& features) { +inline std::string format_as(const OpenTypeFeatureFlags& features) { std::string result; for (const auto& f : features) { if (!result.empty()) { @@ -882,12 +492,12 @@ struct Font { FontStyle style = FontStyle::Normal; ///< The style of the font (e.g., normal, italic). FontWeight weight = FontWeight::Regular; ///< The weight of the font (e.g., regular, bold). TextDecoration textDecoration = TextDecoration::None; ///< Text decoration (e.g., underline, none). - float lineHeight = 1.2f; ///< Line height as a multiplier. - float tabWidth = 8.f; ///< Tab width in space units. - float letterSpacing = 0.f; ///< Additional space between letters. - float wordSpacing = 0.f; ///< Additional space between words. - float verticalAlign = 0.f; ///< Vertical alignment offset. - OpenTypeFeatureFlags features{}; ///< OpenType features for advanced text styling. + float lineHeight = 0.f; ///< Line height as a multiplier, 0 means natural line height. + float tabWidth = 100.f; ///< Absolute tab interval. + float letterSpacing = 0.f; ///< Additional space between letters. + float wordSpacing = 0.f; ///< Additional space between words. + float verticalAlign = 0.f; ///< Vertical alignment offset. + OpenTypeFeatureFlags features{}; ///< OpenType features for advanced text styling. inline static const std::tuple reflection = { ReflectionField{ "fontFamily", &Font::fontFamily }, @@ -937,16 +547,6 @@ struct Font { /** * @brief Combines font style and weight for simplified handling. */ -struct FontStyleAndWeight { - FontStyle style = FontStyle::Normal; ///< The font style (e.g., normal, italic). - FontWeight weight = FontWeight::Regular; ///< The font weight (e.g., regular, bold). - - /** - * @brief Compares two FontStyleAndWeight objects for equality. - */ - bool operator==(const FontStyleAndWeight& b) const noexcept = default; -}; - struct FontAndColor { Font font; std::optional color; @@ -1027,32 +627,6 @@ struct TextWithOptions { bool operator==(const TextWithOptions& other) const noexcept = default; }; -class FontError : public std::runtime_error { -public: - using std::runtime_error::runtime_error; -}; - -enum class TestRenderFlags { - None = 0, - TextBounds = 1, - GlyphBounds = 2, - Fade = 4, -}; - -template <> -constexpr inline bool isBitFlags = true; - -enum class FontFlags { - Default = 0, - DisableKerning = 1, - DisableHinting = 2, - DisableLigatures = 4, - EnableColor = 8, -}; - -template <> -constexpr inline bool isBitFlags = true; - struct OsFont { std::string family; FontStyle style; @@ -1061,13 +635,6 @@ struct OsFont { fs::path path; }; -namespace Internal { -using ShapingCacheKey = std::tuple; -} -} // namespace Brisk - -namespace Brisk { - class FontManager final { public: /** @@ -1076,7 +643,7 @@ class FontManager final { * @param hscale Horizontal scaling factor (default: 3). * @param cacheTimeMs Cache duration in milliseconds (default: 5000). */ - explicit FontManager(std::recursive_mutex* mutex, int hscale = 3, uint32_t cacheTimeMs = 5000); + explicit FontManager(std::recursive_mutex* mutex, int hscale = 3); /** * @brief Destructor for FontManager. @@ -1090,17 +657,22 @@ class FontManager final { */ void addFontAlias(std::string_view newFontFamily, std::string_view existingFontFamily); + /** Registers every face in a font file held in memory. */ + void addFont(BytesView data, std::string alias = {}); + /** - * @brief Adds a font to the manager with specified properties. - * @param fontFamily The font family name. - * @param style The font style (e.g., italic, normal). - * @param weight The font weight (e.g., bold, regular). - * @param data Binary font data. - * @param makeCopy Whether to create a copy of the data (default: true). - * @param flags Font flags (default: FontFlags::Default). + * @brief Registers every face in a cached embedded resource. + * + * The cached resource has process lifetime, so its bytes are borrowed + * without making another copy. + * + * @param resourceName Embedded resource name. + * @param alias Optional family alias for all registered faces. + * @param emptyOk Allow a missing resource to be ignored. + * @return True if a non-empty resource was registered. */ - void addFont(std::string fontFamily, FontStyle style, FontWeight weight, BytesView data, - bool makeCopy = true, FontFlags flags = FontFlags::Default); + [[nodiscard]] bool addFontFromResource(std::string resourceName, std::string alias = {}, + bool emptyOk = false); /** * @brief Adds a font installed on the system. @@ -1108,14 +680,14 @@ class FontManager final { * @param fontName The specific font name to add. * @return True if the font was successfully added, false otherwise. */ - [[nodiscard]] bool addFontByName(std::string fontFamily, std::string_view fontName); + [[nodiscard]] bool addFontByName(std::string_view fontName, std::string alias = {}); /** * @brief Adds a system font to the manager. * @param fontFamily The font family name to register. * @return True if the system font was successfully added, false otherwise. */ - [[nodiscard]] bool addSystemFont(std::string fontFamily); + [[nodiscard]] bool addSystemFont(std::string alias = {}); /** * @brief Adds a font from a file. @@ -1125,8 +697,7 @@ class FontManager final { * @param path Filesystem path to the font file. * @return Status indicating success or an IoError on failure. */ - [[nodiscard]] status addFontFromFile(std::string fontFamily, FontStyle style, FontWeight weight, - const fs::path& path); + [[nodiscard]] status addFontFromFile(const fs::path& path, std::string alias = {}); /** * @brief Retrieves a list of installed system fonts. @@ -1135,13 +706,6 @@ class FontManager final { */ [[nodiscard]] std::vector installedFonts(bool rescan = false) const; - /** - * @brief Gets available styles and weights for a font family. - * @param fontFamily The font family to query. - * @return Vector of FontStyleAndWeight pairs. - */ - std::vector fontFamilyStyles(std::string_view fontFamily) const; - /** * @brief Retrieves metrics for a given font. * @param font The font to measure. @@ -1157,104 +721,39 @@ class FontManager final { */ [[nodiscard]] bool hasCodepoint(const Font& font, char32_t codepoint) const; - /** - * @brief Prepares text for rendering with a single font. - * @param font The font to use. - * @param text Text with rendering options. - * @param width Maximum width for text layout (default: unlimited). - * @return PreparedText object ready for rendering. - */ - [[nodiscard]] PreparedText prepare(const Font& font, const TextWithOptions& text, - float width = HUGE_VALF) const; - - /** - * @brief Calculates the bounding rectangle for text with a single font. - * @param font The font to use. - * @param text Text with rendering options. - * @param boundsType Type of bounds to calculate (default: GlyphRunBounds::Alignment). - * @return RectangleF representing the text bounds. - */ - [[nodiscard]] RectangleF bounds(const Font& font, const TextWithOptions& text, - GlyphRunBounds boundsType = GlyphRunBounds::Alignment) const; - - /** - * @brief Prepares text for rendering with multiple fonts. - * @param text Text with rendering options. - * @param fonts Span of fonts and their colors. - * @param offsets Span of text offsets for font changes. - * @param width Maximum width for text layout (default: unlimited). - * @return PreparedText object ready for rendering. - */ - [[nodiscard]] PreparedText prepare(const TextWithOptions& text, std::span fonts, - std::span offsets, float width = HUGE_VALF) const; + /** @brief Shapes text with the text-layout engine. */ + [[nodiscard]] ShapedText shapeText(const Font& font, const TextWithOptions& text) const; - /** - * @brief Calculates the bounding rectangle for text with multiple fonts. - * @param text Text with rendering options. - * @param fonts Span of fonts and their colors. - * @param offsets Span of text offsets for font changes. - * @param boundsType Type of bounds to calculate (default: GlyphRunBounds::Alignment). - * @return RectangleF representing the text bounds. - */ - [[nodiscard]] RectangleF bounds(const TextWithOptions& text, std::span fonts, - std::span offsets, - GlyphRunBounds boundsType = GlyphRunBounds::Alignment) const; + [[nodiscard]] ShapedText shapeText(const TextWithOptions& text, std::span fonts, + std::span offsets = {}) const; using FontKey = std::tuple; // Internal use only - FontKey faceToKey(Internal::FontFace* face) const; - // Internal use only - void testRender(Rc image, const PreparedText& run, Point origin, - TestRenderFlags flags = TestRenderFlags::None, std::initializer_list xlines = {}, - std::initializer_list ylines = {}) const; - - // Internal use only - int hscale() const { + int hscale() const noexcept { return m_hscale; } - // Internal use only - void garbageCollectCache(); + /// Sets the maximum memory retained by the new text-layout glyph cache. + void setGlyphCacheMemoryBudget(size_t bytes); + /// Removes all entries from the new text-layout glyph cache. + void clearGlyphCache(); + + void lock() const noexcept; + bool try_lock() const noexcept; + void unlock() const noexcept; private: - friend struct Internal::FontFace; friend struct Font; - std::map> m_fonts; - void* m_ft_library; mutable std::recursive_mutex* m_lock; + void* m_ft_library{}; + std::shared_ptr m_textEngine; - struct ShapeCacheEntry { - PreparedText runs; - uint64_t counter; - }; - - mutable std::unordered_map m_shapeCache; - mutable uint64_t m_cacheCounter = 0; - int m_hscale; - uint32_t m_cacheTimeMs; + const int m_hscale; std::vector fontList(std::string_view ff) const; mutable std::vector m_osFonts; - Internal::FontFace* lookup(const Font& font) const; - Internal::FontFace* findFontByKey(FontKey fontKey) const; - std::pair lookupCodepoint(const Font& font, char32_t codepoint, - bool fallbackToUndef) const; + void addFontImpl(BytesView data, std::string alias, bool makeCopy); FontMetrics getMetrics(const Font& font) const; - static RectangleF glyphBounds(const Internal::Glyph& g, const Internal::GlyphData& d); - PreparedText shapeRuns(const TextWithOptions& text, std::span fonts, - std::span offsets, - const std::vector& textRuns) const; - std::vector assignFontsToTextRuns( - std::u32string_view text, std::span fonts, std::span offsets, - const std::vector& textRuns) const; - std::vector splitControls(std::u32string_view text, - const std::vector& textRuns) const; - PreparedText doPrepare(const TextWithOptions& text, std::span fonts, - std::span offsets, float width = HUGE_VALF) const; - PreparedText doShapeCached(const TextWithOptions& text, std::span fonts, - std::span offsets) const; - PreparedText doShape(const TextWithOptions& text, std::span fonts, - std::span offsets) const; }; extern std::optional fonts; @@ -1268,35 +767,11 @@ inline std::vector textBreakPositions(std::u32string_view text, TextBr return result; } -namespace Internal { -/** - * @brief Splits a string of text into multiple `TextRun` objects based on directionality. - * - * This function analyzes the given text and segments it into runs of uniform properties. It takes into - * account the specified default text direction and optionally applies visual order for bidirectional text. - * - * @param text The text to be split into text runs. - * @param defaultDirection The default text direction to use if no explicit directionality is detected. - * @param visualOrder If `true`, the resulting text runs will be reordered to match the visual order of the - * text. - * @return std::vector A vector of `TextRun` objects representing the segmented text. - */ -std::vector splitTextRuns(std::u32string_view text, TextDirection defaultDirection); - -inline std::vector toVisualOrder(std::vector textRuns) { - std::stable_sort(textRuns.begin(), textRuns.end(), [](const TextRun& a, const TextRun& b) { - return a.visualOrder < b.visualOrder; - }); - return textRuns; -} - -} // namespace Internal - /** * @brief Indicates whether the ICU library is available for full Unicode support. * * When `icuAvailable` is `true`, the font functions will have full Unicode support - * for Bidirectional (BiDi) text processing (using splitTextRuns) and grapheme/line + * for Bidirectional (BiDi) text processing and grapheme/line * breaking functionality (textBreakPositions). * */ diff --git a/include/brisk/graphics/Geometry.hpp b/include/brisk/graphics/Geometry.hpp index e94bb3c0..544a6c60 100644 --- a/include/brisk/graphics/Geometry.hpp +++ b/include/brisk/graphics/Geometry.hpp @@ -406,7 +406,7 @@ struct PointOf { * * @return A PolarOf instance representing the polar coordinates. */ - constexpr operator PolarOf() noexcept { + constexpr operator PolarOf() const noexcept { static_assert(std::is_floating_point_v); return { std::sqrt(x * x + y * y), @@ -670,7 +670,7 @@ struct PointOf { * @return The Manhattan distance to the other point. */ T distanceManhattan(const PointOf& pt) const noexcept { - return horizontalMax(abs(pt.v - v)); + return horizontalSum(abs(pt.v - v)); } /** @@ -804,7 +804,7 @@ struct PointOf { * @param b The second PointOf instance. * @return A new PointOf instance representing the minimum point. */ -template +template BRISK_INLINE PointOf min(const PointOf& a, const PointOf& b) noexcept { return PointOf(min(a.v, b.v)); } @@ -817,7 +817,7 @@ BRISK_INLINE PointOf min(const PointOf& a, const PointOf& b) noexcept { * @param b The second PointOf instance. * @return A new PointOf instance representing the maximum point. */ -template +template BRISK_INLINE PointOf max(const PointOf& a, const PointOf& b) noexcept { return PointOf(max(a.v, b.v)); } @@ -942,7 +942,7 @@ struct SizeOf { /// @brief Constructor that initializes width and height to the same value. /// @param xy The value for both width and height. - constexpr explicit SizeOf(T xy) noexcept : x(xy), y(xy) {} + constexpr SizeOf(T xy) noexcept : x(xy), y(xy) {} /// @brief Constructor that initializes from a SIMD vector. /// @param v The SIMD vector to initialize from. @@ -1161,7 +1161,7 @@ struct SizeOf { * @param b The second SizeOf instance. * @return A SizeOf instance representing the minimum size. */ -template +template BRISK_INLINE SizeOf min(const SizeOf& a, const SizeOf& b) noexcept { return SizeOf(min(a.v, b.v)); } @@ -1174,7 +1174,7 @@ BRISK_INLINE SizeOf min(const SizeOf& a, const SizeOf& b) noexcept { * @param b The second SizeOf instance. * @return A SizeOf instance representing the maximum size. */ -template +template BRISK_INLINE SizeOf max(const SizeOf& a, const SizeOf& b) noexcept { return SizeOf(max(a.v, b.v)); } @@ -1283,7 +1283,7 @@ struct EdgesOf { /// @brief Constructor that initializes all edges to a specific value. /// @param value The value to initialize all edge coordinates. - constexpr explicit EdgesOf(T value) noexcept : v(value) {} + constexpr EdgesOf(T value) noexcept : v(value) {} /// @brief Constructor that initializes edges with specified horizontal and vertical values. /// @param h The horizontal value for x1 and x2. @@ -1480,7 +1480,7 @@ struct EdgesOf { * @param b The second EdgesOf instance. * @return A new EdgesOf instance representing the minimum edges. */ -template +template BRISK_INLINE EdgesOf min(const EdgesOf& a, const EdgesOf& b) noexcept { return EdgesOf(min(a.v, b.v)); } @@ -1493,7 +1493,7 @@ BRISK_INLINE EdgesOf min(const EdgesOf& a, const EdgesOf& b) noexcept { * @param b The second EdgesOf instance. * @return A new EdgesOf instance representing the maximum edges. */ -template +template BRISK_INLINE EdgesOf max(const EdgesOf& a, const EdgesOf& b) noexcept { return EdgesOf(max(a.v, b.v)); } @@ -1842,7 +1842,7 @@ struct RectangleOf { * @return The length of the longest side. */ constexpr T longestSide() const noexcept { - return size().longestSize(); + return size().longestSide(); } /** @@ -1885,7 +1885,10 @@ struct RectangleOf { * @return The normalized coordinates as a PointOf. */ PointOf toNormCoord(const PointOf& pt) const noexcept { - return PointOf((pt.v - p1.v) / (p1.v - p1.v)); + const Simd a{ pt.v }; + const Simd b{ p1.v }; + const Simd c{ p2.v }; + return PointOf((a - b) / (c - b)); } /** @@ -1898,7 +1901,7 @@ struct RectangleOf { PointOf toNormCoord(const PointOf& pt, const PointOf& ifoutside) const noexcept { if (!contains(pt)) return ifoutside; - return PointOf((pt.v - p1.v) / (p2.v - p1.v)); + return toNormCoord(pt); } /** @@ -1910,8 +1913,9 @@ struct RectangleOf { */ RectangleOf split(const PointOf& point1, const SizeOf& size) const noexcept { const Simd point2 = point1.v + size.v; - return RectangleOf( - concat(Simd(p1.v + this->size().v * point1.v), Simd(p1.v + this->size().v * point2))); + const Simd base{ p1.v }; + const Simd sz{ this->size().v }; + return RectangleOf(concat(Simd(base + sz * point1.v), Simd(base + sz * point2))); } /** @@ -1954,7 +1958,7 @@ struct RectangleOf { * @param p The new starting point. */ void applyStart(const PointOf& p) noexcept { - v = concat(p.v, p.v + size()); + v = concat(p.v, p.v + size().v); } /** @@ -1983,7 +1987,7 @@ struct RectangleOf { * @param h The new height. */ void applySize(T w, T h) noexcept { - v = concat(v.low(), v.low() + pack(w, h)); + v = concat(v.low(), v.low() + Simd{ w, h }); } /** @@ -2037,20 +2041,20 @@ struct RectangleOf { * @brief Applies a margin to the rectangle. * * @param h The horizontal margin. - * @param v The vertical margin. + * @param vertical The vertical margin. */ - void applyMargin(T h, T v) noexcept { - v += Simd(-h, -v, +h, +v); + void applyMargin(T h, T vertical) noexcept { + v += Simd(-h, -vertical, +h, +vertical); } /** * @brief Applies padding to the rectangle. * * @param h The horizontal padding. - * @param v The vertical padding. + * @param vertical The vertical padding. */ - void applyPadding(T h, T v) noexcept { - v += Simd(+h, +v, -h, -v); + void applyPadding(T h, T vertical) noexcept { + v += Simd(+h, +vertical, -h, -vertical); } /** @@ -2135,7 +2139,7 @@ struct RectangleOf { * @return A new RectangleOf with the specified starting point. */ constexpr RectangleOf withStart(const PointOf& p) const noexcept { - return RectangleOf(concat(p.v, p.v + size())); + return RectangleOf(concat(p.v, p.v + size().v)); } /** @@ -2146,7 +2150,7 @@ struct RectangleOf { * @return A new RectangleOf with the specified starting point. */ constexpr RectangleOf withStart(T x, T y) const noexcept { - return RectangleOf(concat(Simd{ x, y }, Simd{ x, y } + size())); + return RectangleOf(concat(Simd{ x, y }, Simd{ x, y } + size().v)); } /** diff --git a/include/brisk/graphics/Gradients.hpp b/include/brisk/graphics/Gradients.hpp index accd5025..dd3c62f2 100644 --- a/include/brisk/graphics/Gradients.hpp +++ b/include/brisk/graphics/Gradients.hpp @@ -46,7 +46,7 @@ struct ColorStop { enum class GradientType : uint8_t { Linear, ///< A linear gradient. Radial, ///< A radial gradient. - Angle, ///< An angular (conic) gradient. + Angular, ///< An angular (conic) gradient. Reflected, ///< A reflected gradient. }; diff --git a/include/brisk/graphics/internal/OpenType.hpp b/include/brisk/graphics/internal/OpenType.hpp index e80fef2a..ecc53ce3 100644 --- a/include/brisk/graphics/internal/OpenType.hpp +++ b/include/brisk/graphics/internal/OpenType.hpp @@ -25,262 +25,270 @@ namespace Brisk { +/** + * @brief Packs four characters into a 32-bit OpenType feature tag, + * similar to HarfBuzz's @c HB_TAG macro. + */ +constexpr uint32_t otTag(char c1, char c2, char c3, char c4) { + return ((uint32_t(c1) & 0xFF) << 24) | ((uint32_t(c2) & 0xFF) << 16) | ((uint32_t(c3) & 0xFF) << 8) | + (uint32_t(c4) & 0xFF); +} + /** * @enum OpenTypeFeature * @brief Enumerates various OpenType font features. * - * @note Use openTypeFeatures to map to uint32_t. + * Each enumerator holds the corresponding 4-byte OpenType feature tag + * (as produced by otTag), so it can be passed directly to shaping APIs. */ -enum class OpenTypeFeature : uint8_t { - aalt, /**< Access All Alternates */ - abvf, /**< Above-base Forms */ - abvm, /**< Above-base Mark Positioning */ - abvs, /**< Above-base Substitutions */ - afrc, /**< Alternative Fractions */ - akhn, /**< Akhand - Forms a conjunct */ - blwf, /**< Below-base Forms */ - blwm, /**< Below-base Mark Positioning */ - blws, /**< Below-base Substitutions */ - calt, /**< Contextual Alternates */ - case_, /**< Case-Sensitive Forms */ - ccmp, /**< Glyph Composition/Decomposition */ - cfar, /**< Conjunct Form After Ro */ - chws, /**< Contextual Half-width Spacing */ - cjct, /**< Conjunct Forms */ - clig, /**< Contextual Ligatures */ - cpct, /**< Centered CJK Punctuation */ - cpsp, /**< Capital Spacing */ - cswh, /**< Contextual Swash */ - curs, /**< Cursive Positioning */ - cv01, /**< Character Variants (1) */ - cv02, /**< Character Variants (2) */ +enum class OpenTypeFeature : uint32_t { + aalt = otTag('a', 'a', 'l', 't'), /**< Access All Alternates */ + abvf = otTag('a', 'b', 'v', 'f'), /**< Above-base Forms */ + abvm = otTag('a', 'b', 'v', 'm'), /**< Above-base Mark Positioning */ + abvs = otTag('a', 'b', 'v', 's'), /**< Above-base Substitutions */ + afrc = otTag('a', 'f', 'r', 'c'), /**< Alternative Fractions */ + akhn = otTag('a', 'k', 'h', 'n'), /**< Akhand - Forms a conjunct */ + blwf = otTag('b', 'l', 'w', 'f'), /**< Below-base Forms */ + blwm = otTag('b', 'l', 'w', 'm'), /**< Below-base Mark Positioning */ + blws = otTag('b', 'l', 'w', 's'), /**< Below-base Substitutions */ + calt = otTag('c', 'a', 'l', 't'), /**< Contextual Alternates */ + case_ = otTag('c', 'a', 's', 'e'), /**< Case-Sensitive Forms */ + ccmp = otTag('c', 'c', 'm', 'p'), /**< Glyph Composition/Decomposition */ + cfar = otTag('c', 'f', 'a', 'r'), /**< Conjunct Form After Ro */ + chws = otTag('c', 'h', 'w', 's'), /**< Contextual Half-width Spacing */ + cjct = otTag('c', 'j', 'c', 't'), /**< Conjunct Forms */ + clig = otTag('c', 'l', 'i', 'g'), /**< Contextual Ligatures */ + cpct = otTag('c', 'p', 'c', 't'), /**< Centered CJK Punctuation */ + cpsp = otTag('c', 'p', 's', 'p'), /**< Capital Spacing */ + cswh = otTag('c', 's', 'w', 'h'), /**< Contextual Swash */ + curs = otTag('c', 'u', 'r', 's'), /**< Cursive Positioning */ + cv01 = otTag('c', 'v', '0', '1'), /**< Character Variants (1) */ + cv02 = otTag('c', 'v', '0', '2'), /**< Character Variants (2) */ #ifndef DOCUMENTATION - cv03, - cv04, - cv05, - cv06, - cv07, - cv08, - cv09, - cv10, - cv11, - cv12, - cv13, - cv14, - cv15, - cv16, - cv17, - cv18, - cv19, - cv20, - cv21, - cv22, - cv23, - cv24, - cv25, - cv26, - cv27, - cv28, - cv29, - cv30, - cv31, - cv32, - cv33, - cv34, - cv35, - cv36, - cv37, - cv38, - cv39, - cv40, - cv41, - cv42, - cv43, - cv44, - cv45, - cv46, - cv47, - cv48, - cv49, - cv50, - cv51, - cv52, - cv53, - cv54, - cv55, - cv56, - cv57, - cv58, - cv59, - cv60, - cv61, - cv62, - cv63, - cv64, - cv65, - cv66, - cv67, - cv68, - cv69, - cv70, - cv71, - cv72, - cv73, - cv74, - cv75, - cv76, - cv77, - cv78, - cv79, - cv80, - cv81, - cv82, - cv83, - cv84, - cv85, - cv86, - cv87, - cv88, - cv89, - cv90, - cv91, - cv92, - cv93, - cv94, - cv95, - cv96, - cv97, - cv98, - cv99, /**< Character Variants (99) */ + cv03 = otTag('c', 'v', '0', '3'), + cv04 = otTag('c', 'v', '0', '4'), + cv05 = otTag('c', 'v', '0', '5'), + cv06 = otTag('c', 'v', '0', '6'), + cv07 = otTag('c', 'v', '0', '7'), + cv08 = otTag('c', 'v', '0', '8'), + cv09 = otTag('c', 'v', '0', '9'), + cv10 = otTag('c', 'v', '1', '0'), + cv11 = otTag('c', 'v', '1', '1'), + cv12 = otTag('c', 'v', '1', '2'), + cv13 = otTag('c', 'v', '1', '3'), + cv14 = otTag('c', 'v', '1', '4'), + cv15 = otTag('c', 'v', '1', '5'), + cv16 = otTag('c', 'v', '1', '6'), + cv17 = otTag('c', 'v', '1', '7'), + cv18 = otTag('c', 'v', '1', '8'), + cv19 = otTag('c', 'v', '1', '9'), + cv20 = otTag('c', 'v', '2', '0'), + cv21 = otTag('c', 'v', '2', '1'), + cv22 = otTag('c', 'v', '2', '2'), + cv23 = otTag('c', 'v', '2', '3'), + cv24 = otTag('c', 'v', '2', '4'), + cv25 = otTag('c', 'v', '2', '5'), + cv26 = otTag('c', 'v', '2', '6'), + cv27 = otTag('c', 'v', '2', '7'), + cv28 = otTag('c', 'v', '2', '8'), + cv29 = otTag('c', 'v', '2', '9'), + cv30 = otTag('c', 'v', '3', '0'), + cv31 = otTag('c', 'v', '3', '1'), + cv32 = otTag('c', 'v', '3', '2'), + cv33 = otTag('c', 'v', '3', '3'), + cv34 = otTag('c', 'v', '3', '4'), + cv35 = otTag('c', 'v', '3', '5'), + cv36 = otTag('c', 'v', '3', '6'), + cv37 = otTag('c', 'v', '3', '7'), + cv38 = otTag('c', 'v', '3', '8'), + cv39 = otTag('c', 'v', '3', '9'), + cv40 = otTag('c', 'v', '4', '0'), + cv41 = otTag('c', 'v', '4', '1'), + cv42 = otTag('c', 'v', '4', '2'), + cv43 = otTag('c', 'v', '4', '3'), + cv44 = otTag('c', 'v', '4', '4'), + cv45 = otTag('c', 'v', '4', '5'), + cv46 = otTag('c', 'v', '4', '6'), + cv47 = otTag('c', 'v', '4', '7'), + cv48 = otTag('c', 'v', '4', '8'), + cv49 = otTag('c', 'v', '4', '9'), + cv50 = otTag('c', 'v', '5', '0'), + cv51 = otTag('c', 'v', '5', '1'), + cv52 = otTag('c', 'v', '5', '2'), + cv53 = otTag('c', 'v', '5', '3'), + cv54 = otTag('c', 'v', '5', '4'), + cv55 = otTag('c', 'v', '5', '5'), + cv56 = otTag('c', 'v', '5', '6'), + cv57 = otTag('c', 'v', '5', '7'), + cv58 = otTag('c', 'v', '5', '8'), + cv59 = otTag('c', 'v', '5', '9'), + cv60 = otTag('c', 'v', '6', '0'), + cv61 = otTag('c', 'v', '6', '1'), + cv62 = otTag('c', 'v', '6', '2'), + cv63 = otTag('c', 'v', '6', '3'), + cv64 = otTag('c', 'v', '6', '4'), + cv65 = otTag('c', 'v', '6', '5'), + cv66 = otTag('c', 'v', '6', '6'), + cv67 = otTag('c', 'v', '6', '7'), + cv68 = otTag('c', 'v', '6', '8'), + cv69 = otTag('c', 'v', '6', '9'), + cv70 = otTag('c', 'v', '7', '0'), + cv71 = otTag('c', 'v', '7', '1'), + cv72 = otTag('c', 'v', '7', '2'), + cv73 = otTag('c', 'v', '7', '3'), + cv74 = otTag('c', 'v', '7', '4'), + cv75 = otTag('c', 'v', '7', '5'), + cv76 = otTag('c', 'v', '7', '6'), + cv77 = otTag('c', 'v', '7', '7'), + cv78 = otTag('c', 'v', '7', '8'), + cv79 = otTag('c', 'v', '7', '9'), + cv80 = otTag('c', 'v', '8', '0'), + cv81 = otTag('c', 'v', '8', '1'), + cv82 = otTag('c', 'v', '8', '2'), + cv83 = otTag('c', 'v', '8', '3'), + cv84 = otTag('c', 'v', '8', '4'), + cv85 = otTag('c', 'v', '8', '5'), + cv86 = otTag('c', 'v', '8', '6'), + cv87 = otTag('c', 'v', '8', '7'), + cv88 = otTag('c', 'v', '8', '8'), + cv89 = otTag('c', 'v', '8', '9'), + cv90 = otTag('c', 'v', '9', '0'), + cv91 = otTag('c', 'v', '9', '1'), + cv92 = otTag('c', 'v', '9', '2'), + cv93 = otTag('c', 'v', '9', '3'), + cv94 = otTag('c', 'v', '9', '4'), + cv95 = otTag('c', 'v', '9', '5'), + cv96 = otTag('c', 'v', '9', '6'), + cv97 = otTag('c', 'v', '9', '7'), + cv98 = otTag('c', 'v', '9', '8'), + cv99 = otTag('c', 'v', '9', '9'), /**< Character Variants (99) */ #endif - c2pc, /**< Petite Capitals From Capitals */ - c2sc, /**< Small Capitals From Capitals */ - dist, /**< Distances */ - dlig, /**< Discretionary Ligatures */ - dnom, /**< Denominators */ - dtls, /**< Dotless Forms */ - expt, /**< Expert Forms */ - falt, /**< Final Alternates */ - fin2, /**< Terminal Forms #2 */ - fin3, /**< Terminal Forms #3 */ - fina, /**< Terminal Forms */ - flac, /**< Flattened accent forms */ - frac, /**< Fractions */ - fwid, /**< Full Width */ - half, /**< Half Forms */ - haln, /**< Halant Forms */ - halt, /**< Alternate Half Width */ - hist, /**< Historical Forms */ - hkna, /**< Horizontal Kana Alternates */ - hlig, /**< Historical Ligatures */ - hngl, /**< Hangul */ - hojo, /**< Hojo Kanji Forms */ - hwid, /**< Half Width */ - init, /**< Initial Forms */ - isol, /**< Isolated Forms */ - ital, /**< Italics */ - jalt, /**< Justification Alternates */ - jp78, /**< Japanese Forms 1978 */ - jp83, /**< Japanese Forms 1983 */ - jp90, /**< Japanese Forms 1990 */ - jp04, /**< Japanese Forms 2004 */ - kern, /**< Kerning */ - lfbd, /**< Left Bounds */ - liga, /**< Standard Ligatures */ - ljmo, /**< Leading Jamo Forms */ - lnum, /**< Lining Figures */ - locl, /**< Localized Forms */ - ltra, /**< Left-to-Right Alternates */ - ltrm, /**< Left-to-Right Mirrored Forms */ - mark, /**< Mark Positioning */ - med2, /**< Medial Forms #2 */ - medi, /**< Medial Forms */ - mgrk, /**< Mathematical Greek */ - mkmk, /**< Mark-to-Mark Positioning */ - mset, /**< Mark Positioning via Substitution */ - nalt, /**< Alternate Annotation Forms */ - nlck, /**< NLC Kanji Forms */ - nukt, /**< Nukta Forms */ - numr, /**< Numerators */ - onum, /**< Oldstyle Figures */ - opbd, /**< Optical Bounds */ - ordn, /**< Ordinals */ - ornm, /**< Ornaments */ - palt, /**< Proportional Alternate Width */ - pcap, /**< Petite Capitals */ - pkna, /**< Proportional Kana */ - pnum, /**< Proportional Figures */ - pref, /**< Pre-Base Forms */ - pres, /**< Pre-Below Substitutions */ - pstf, /**< Post-Base Forms */ - psts, /**< Post-Below Substitutions */ - pwid, /**< Proportional Widths */ - qwid, /**< Quarter Widths */ - rand, /**< Randomize */ - rclt, /**< Required Contextual Alternates */ - rkrf, /**< Rakar Forms */ - rlig, /**< Required Ligatures */ - rphf, /**< Reph Forms */ - rtbd, /**< Right Bounds */ - rtla, /**< Right-to-Left Alternates */ - rtlm, /**< Right-to-Left Mirrored Forms */ - ruby, /**< Ruby Notation Forms */ - rvrn, /**< Required Variation Alternates */ - salt, /**< Stylistic Alternates */ - sinf, /**< Scientific Inferiors */ - size, /**< Optical Size */ - smcp, /**< Small Capitals */ - smpl, /**< Simplified Forms */ - ss01, /**< Stylistic Set 1 */ - ss02, /**< Stylistic Set 2 */ - ss03, /**< Stylistic Set 3 */ + c2pc = otTag('c', '2', 'p', 'c'), /**< Petite Capitals From Capitals */ + c2sc = otTag('c', '2', 's', 'c'), /**< Small Capitals From Capitals */ + dist = otTag('d', 'i', 's', 't'), /**< Distances */ + dlig = otTag('d', 'l', 'i', 'g'), /**< Discretionary Ligatures */ + dnom = otTag('d', 'n', 'o', 'm'), /**< Denominators */ + dtls = otTag('d', 't', 'l', 's'), /**< Dotless Forms */ + expt = otTag('e', 'x', 'p', 't'), /**< Expert Forms */ + falt = otTag('f', 'a', 'l', 't'), /**< Final Alternates */ + fin2 = otTag('f', 'i', 'n', '2'), /**< Terminal Forms #2 */ + fin3 = otTag('f', 'i', 'n', '3'), /**< Terminal Forms #3 */ + fina = otTag('f', 'i', 'n', 'a'), /**< Terminal Forms */ + flac = otTag('f', 'l', 'a', 'c'), /**< Flattened accent forms */ + frac = otTag('f', 'r', 'a', 'c'), /**< Fractions */ + fwid = otTag('f', 'w', 'i', 'd'), /**< Full Width */ + half = otTag('h', 'a', 'l', 'f'), /**< Half Forms */ + haln = otTag('h', 'a', 'l', 'n'), /**< Halant Forms */ + halt = otTag('h', 'a', 'l', 't'), /**< Alternate Half Width */ + hist = otTag('h', 'i', 's', 't'), /**< Historical Forms */ + hkna = otTag('h', 'k', 'n', 'a'), /**< Horizontal Kana Alternates */ + hlig = otTag('h', 'l', 'i', 'g'), /**< Historical Ligatures */ + hngl = otTag('h', 'n', 'g', 'l'), /**< Hangul */ + hojo = otTag('h', 'o', 'j', 'o'), /**< Hojo Kanji Forms */ + hwid = otTag('h', 'w', 'i', 'd'), /**< Half Width */ + init = otTag('i', 'n', 'i', 't'), /**< Initial Forms */ + isol = otTag('i', 's', 'o', 'l'), /**< Isolated Forms */ + ital = otTag('i', 't', 'a', 'l'), /**< Italics */ + jalt = otTag('j', 'a', 'l', 't'), /**< Justification Alternates */ + jp78 = otTag('j', 'p', '7', '8'), /**< Japanese Forms 1978 */ + jp83 = otTag('j', 'p', '8', '3'), /**< Japanese Forms 1983 */ + jp90 = otTag('j', 'p', '9', '0'), /**< Japanese Forms 1990 */ + jp04 = otTag('j', 'p', '0', '4'), /**< Japanese Forms 2004 */ + kern = otTag('k', 'e', 'r', 'n'), /**< Kerning */ + lfbd = otTag('l', 'f', 'b', 'd'), /**< Left Bounds */ + liga = otTag('l', 'i', 'g', 'a'), /**< Standard Ligatures */ + ljmo = otTag('l', 'j', 'm', 'o'), /**< Leading Jamo Forms */ + lnum = otTag('l', 'n', 'u', 'm'), /**< Lining Figures */ + locl = otTag('l', 'o', 'c', 'l'), /**< Localized Forms */ + ltra = otTag('l', 't', 'r', 'a'), /**< Left-to-Right Alternates */ + ltrm = otTag('l', 't', 'r', 'm'), /**< Left-to-Right Mirrored Forms */ + mark = otTag('m', 'a', 'r', 'k'), /**< Mark Positioning */ + med2 = otTag('m', 'e', 'd', '2'), /**< Medial Forms #2 */ + medi = otTag('m', 'e', 'd', 'i'), /**< Medial Forms */ + mgrk = otTag('m', 'g', 'r', 'k'), /**< Mathematical Greek */ + mkmk = otTag('m', 'k', 'm', 'k'), /**< Mark-to-Mark Positioning */ + mset = otTag('m', 's', 'e', 't'), /**< Mark Positioning via Substitution */ + nalt = otTag('n', 'a', 'l', 't'), /**< Alternate Annotation Forms */ + nlck = otTag('n', 'l', 'c', 'k'), /**< NLC Kanji Forms */ + nukt = otTag('n', 'u', 'k', 't'), /**< Nukta Forms */ + numr = otTag('n', 'u', 'm', 'r'), /**< Numerators */ + onum = otTag('o', 'n', 'u', 'm'), /**< Oldstyle Figures */ + opbd = otTag('o', 'p', 'b', 'd'), /**< Optical Bounds */ + ordn = otTag('o', 'r', 'd', 'n'), /**< Ordinals */ + ornm = otTag('o', 'r', 'n', 'm'), /**< Ornaments */ + palt = otTag('p', 'a', 'l', 't'), /**< Proportional Alternate Width */ + pcap = otTag('p', 'c', 'a', 'p'), /**< Petite Capitals */ + pkna = otTag('p', 'k', 'n', 'a'), /**< Proportional Kana */ + pnum = otTag('p', 'n', 'u', 'm'), /**< Proportional Figures */ + pref = otTag('p', 'r', 'e', 'f'), /**< Pre-Base Forms */ + pres = otTag('p', 'r', 'e', 's'), /**< Pre-Below Substitutions */ + pstf = otTag('p', 's', 't', 'f'), /**< Post-Base Forms */ + psts = otTag('p', 's', 't', 's'), /**< Post-Below Substitutions */ + pwid = otTag('p', 'w', 'i', 'd'), /**< Proportional Widths */ + qwid = otTag('q', 'w', 'i', 'd'), /**< Quarter Widths */ + rand = otTag('r', 'a', 'n', 'd'), /**< Randomize */ + rclt = otTag('r', 'c', 'l', 't'), /**< Required Contextual Alternates */ + rkrf = otTag('r', 'k', 'r', 'f'), /**< Rakar Forms */ + rlig = otTag('r', 'l', 'i', 'g'), /**< Required Ligatures */ + rphf = otTag('r', 'p', 'h', 'f'), /**< Reph Forms */ + rtbd = otTag('r', 't', 'b', 'd'), /**< Right Bounds */ + rtla = otTag('r', 't', 'l', 'a'), /**< Right-to-Left Alternates */ + rtlm = otTag('r', 't', 'l', 'm'), /**< Right-to-Left Mirrored Forms */ + ruby = otTag('r', 'u', 'b', 'y'), /**< Ruby Notation Forms */ + rvrn = otTag('r', 'v', 'r', 'n'), /**< Required Variation Alternates */ + salt = otTag('s', 'a', 'l', 't'), /**< Stylistic Alternates */ + sinf = otTag('s', 'i', 'n', 'f'), /**< Scientific Inferiors */ + size = otTag('s', 'i', 'z', 'e'), /**< Optical Size */ + smcp = otTag('s', 'm', 'c', 'p'), /**< Small Capitals */ + smpl = otTag('s', 'm', 'p', 'l'), /**< Simplified Forms */ + ss01 = otTag('s', 's', '0', '1'), /**< Stylistic Set 1 */ + ss02 = otTag('s', 's', '0', '2'), /**< Stylistic Set 2 */ + ss03 = otTag('s', 's', '0', '3'), /**< Stylistic Set 3 */ #ifndef DOCUMENTATION - ss04, /**< Stylistic Set 4 */ - ss05, /**< Stylistic Set 5 */ - ss06, /**< Stylistic Set 6 */ - ss07, /**< Stylistic Set 7 */ - ss08, /**< Stylistic Set 8 */ - ss09, /**< Stylistic Set 9 */ - ss10, /**< Stylistic Set 10 */ - ss11, /**< Stylistic Set 11 */ - ss12, /**< Stylistic Set 12 */ - ss13, /**< Stylistic Set 13 */ - ss14, /**< Stylistic Set 14 */ - ss15, /**< Stylistic Set 15 */ - ss16, /**< Stylistic Set 16 */ - ss17, /**< Stylistic Set 17 */ - ss18, /**< Stylistic Set 18 */ - ss19, /**< Stylistic Set 19 */ - ss20, /**< Stylistic Set 20 */ + ss04 = otTag('s', 's', '0', '4'), /**< Stylistic Set 4 */ + ss05 = otTag('s', 's', '0', '5'), /**< Stylistic Set 5 */ + ss06 = otTag('s', 's', '0', '6'), /**< Stylistic Set 6 */ + ss07 = otTag('s', 's', '0', '7'), /**< Stylistic Set 7 */ + ss08 = otTag('s', 's', '0', '8'), /**< Stylistic Set 8 */ + ss09 = otTag('s', 's', '0', '9'), /**< Stylistic Set 9 */ + ss10 = otTag('s', 's', '1', '0'), /**< Stylistic Set 10 */ + ss11 = otTag('s', 's', '1', '1'), /**< Stylistic Set 11 */ + ss12 = otTag('s', 's', '1', '2'), /**< Stylistic Set 12 */ + ss13 = otTag('s', 's', '1', '3'), /**< Stylistic Set 13 */ + ss14 = otTag('s', 's', '1', '4'), /**< Stylistic Set 14 */ + ss15 = otTag('s', 's', '1', '5'), /**< Stylistic Set 15 */ + ss16 = otTag('s', 's', '1', '6'), /**< Stylistic Set 16 */ + ss17 = otTag('s', 's', '1', '7'), /**< Stylistic Set 17 */ + ss18 = otTag('s', 's', '1', '8'), /**< Stylistic Set 18 */ + ss19 = otTag('s', 's', '1', '9'), /**< Stylistic Set 19 */ + ss20 = otTag('s', 's', '2', '0'), /**< Stylistic Set 20 */ #endif - ssty, /**< Script Style */ - stch, /**< Stretching Glyph Deformation */ - subs, /**< Subscript */ - sups, /**< Superscript */ - swsh, /**< Swash */ - titl, /**< Titling Alternates */ - tjmo, /**< Trailing Jamo Forms */ - tnam, /**< Traditional Name Forms */ - tnum, /**< Tabular Figures */ - trad, /**< Traditional Forms */ - twid, /**< Third Widths */ - unic, /**< Unicase */ - valt, /**< Alternate Vertical Metrics */ - vatu, /**< Vattu Variants */ - vchw, /**< Vertical Counterparts to Half-width */ - vert, /**< Vertical Writing */ - vhal, /**< Vertical Alternate Half-width */ - vjmo, /**< Vertical Jamo Forms */ - vkna, /**< Vertical Kana Alternates */ - vkrn, /**< Vertical Kerning */ - vpal, /**< Proportional Alternate Vertical Metrics */ - vrt2, /**< Vertical Alternates and Rotation */ - vrtr, /**< Vertical Alternates for Rotation */ - zero /**< Slashed Zero */ + ssty = otTag('s', 's', 't', 'y'), /**< Script Style */ + stch = otTag('s', 't', 'c', 'h'), /**< Stretching Glyph Deformation */ + subs = otTag('s', 'u', 'b', 's'), /**< Subscript */ + sups = otTag('s', 'u', 'p', 's'), /**< Superscript */ + swsh = otTag('s', 'w', 's', 'h'), /**< Swash */ + titl = otTag('t', 'i', 't', 'l'), /**< Titling Alternates */ + tjmo = otTag('t', 'j', 'm', 'o'), /**< Trailing Jamo Forms */ + tnam = otTag('t', 'n', 'a', 'm'), /**< Traditional Name Forms */ + tnum = otTag('t', 'n', 'u', 'm'), /**< Tabular Figures */ + trad = otTag('t', 'r', 'a', 'd'), /**< Traditional Forms */ + twid = otTag('t', 'w', 'i', 'd'), /**< Third Widths */ + unic = otTag('u', 'n', 'i', 'c'), /**< Unicase */ + valt = otTag('v', 'a', 'l', 't'), /**< Alternate Vertical Metrics */ + vatu = otTag('v', 'a', 't', 'u'), /**< Vattu Variants */ + vchw = otTag('v', 'c', 'h', 'w'), /**< Vertical Counterparts to Half-width */ + vert = otTag('v', 'e', 'r', 't'), /**< Vertical Writing */ + vhal = otTag('v', 'h', 'a', 'l'), /**< Vertical Alternate Half-width */ + vjmo = otTag('v', 'j', 'm', 'o'), /**< Vertical Jamo Forms */ + vkna = otTag('v', 'k', 'n', 'a'), /**< Vertical Kana Alternates */ + vkrn = otTag('v', 'k', 'r', 'n'), /**< Vertical Kerning */ + vpal = otTag('v', 'p', 'a', 'l'), /**< Proportional Alternate Vertical Metrics */ + vrt2 = otTag('v', 'r', 't', '2'), /**< Vertical Alternates and Rotation */ + vrtr = otTag('v', 'r', 't', 'r'), /**< Vertical Alternates for Rotation */ + zero = otTag('z', 'e', 'r', 'o') /**< Slashed Zero */ }; -extern const uint32_t openTypeFeatures[241]; - std::string openTypeFeatureToString(OpenTypeFeature feat); } // namespace Brisk diff --git a/include/brisk/gui/Gui.hpp b/include/brisk/gui/Gui.hpp index 22cf9fb3..417b9b4c 100644 --- a/include/brisk/gui/Gui.hpp +++ b/include/brisk/gui/Gui.hpp @@ -47,6 +47,7 @@ BRISK_CLANG_PRAGMA(clang diagnostic ignored "-Wc++2a-extensions") #include #include #include +#include #include #include #include "internal/Animation.hpp" @@ -381,6 +382,11 @@ struct IsConstexprCompatible> { constexpr static bool value = false; }; +template +struct IsConstexprCompatible> { + constexpr static bool value = false; +}; + template struct GuiProp { ValueType(WidgetClass::* field); @@ -1134,7 +1140,8 @@ class WIDGET Widget : public BindableObject { EdgesF m_computedBorderWidth{ 0, 0, 0, 0 }; Size m_contentSize{ 0, 0 }; Point m_hintTextOffset{ 0, 0 }; - PreparedText m_hintPrepared; + ShapedText m_hintShapedText; + TextLayout m_hintTextLayout; Animated m_backgroundColor; Animated m_borderColor; diff --git a/include/brisk/widgets/Text.hpp b/include/brisk/widgets/Text.hpp index 825c6354..01ff3183 100644 --- a/include/brisk/widgets/Text.hpp +++ b/include/brisk/widgets/Text.hpp @@ -73,17 +73,18 @@ class WIDGET Text : public Widget { }; struct Cached { - PreparedText shaped; + ShapedText shapedText; }; struct CacheKey2 { int width; + TextLayoutAlignment alignment; bool operator==(const CacheKey2&) const noexcept = default; }; struct Cached2 { SizeF textSize; - PreparedText prepared; + TextLayout layout; }; Cached updateCache(const CacheKey&); @@ -123,7 +124,7 @@ class WIDGET Text : public Widget { Property wordWrap; Property rotation; Property textAutoSize; - Property, 4> textAutoSizeRange; + Property, 4> textAutoSizeRange; Property textOptions; BRISK_PROPERTIES_END }; diff --git a/include/brisk/widgets/TextEditor.hpp b/include/brisk/widgets/TextEditor.hpp index 64c0605d..f7f7c731 100644 --- a/include/brisk/widgets/TextEditor.hpp +++ b/include/brisk/widgets/TextEditor.hpp @@ -22,6 +22,8 @@ #include "Widgets.hpp" #include +#include +#include namespace Brisk { @@ -48,7 +50,11 @@ class WIDGET TextEditor : public Widget { std::u32string m_cachedText; - mutable PreparedText m_preparedText; + mutable ShapedText m_shapedText; + mutable TextLayout m_textLayout; + CaretAffinity m_caretAffinity = CaretAffinity::Downstream; + std::optional m_preferredCaretX; + float m_cachedLayoutWidth = -1.f; Font m_cachedFont{}; double m_blinkTime = 0.0; @@ -57,7 +63,7 @@ class WIDGET TextEditor : public Widget { bool m_multiline = false; void resetBlinking(); void updateGraphemes(); - void makeCursorVisible(uint32_t textLen); + void makeCursorVisible(); void selectAll(const std::u32string& text); void deleteSelection(std::u32string& text); void pasteFromClipboard(std::u32string& text); @@ -72,6 +78,10 @@ class WIDGET TextEditor : public Widget { void normalizeCursor(uint32_t textLen); void createContextMenu(); void selectionChanged(); + CaretIndex caretIndex() const; + void setCaretIndex(CaretIndex caret); + CaretIndex caretAtPoint(PointF pt) const; + PointF textOrigin() const; public: static const auto& properties() noexcept { diff --git a/resources/fonts/GoNotoCurrent-Regular.ttf b/resources/fonts/GoNotoKurrent-Regular.ttf similarity index 86% rename from resources/fonts/GoNotoCurrent-Regular.ttf rename to resources/fonts/GoNotoKurrent-Regular.ttf index 948d1a63..9e87b642 100644 Binary files a/resources/fonts/GoNotoCurrent-Regular.ttf and b/resources/fonts/GoNotoKurrent-Regular.ttf differ diff --git a/resources/shaders/webgpu.wgsl b/resources/shaders/webgpu.wgsl index 33d7e272..e01633a9 100644 --- a/resources/shaders/webgpu.wgsl +++ b/resources/shaders/webgpu.wgsl @@ -874,7 +874,7 @@ fn sdfInfLine(p: vec2, p1: vec2, p2: vec2) -> f32 { return dot(p - p1, normal); } -fn angleGradient(p: vec2, p1: vec2, p2: vec2) -> f32 { +fn angularGradient(p: vec2, p1: vec2, p2: vec2) -> f32 { let sd = sdfInfLine(p, p1, p2); let n = map(normalize(p - p1), normalize(p2 - p1)); let first = atan2(n.x, n.y) * 0.15915494309 + 0.75; @@ -888,7 +888,7 @@ fn gradientPositionForPoint(point: vec2) -> f32 { } else if constant_gradient_type() == gradient_radial { return length(point - constants.gradient_point1) / length(constants.gradient_point2 - constants.gradient_point1); } else if constant_gradient_type() == gradient_angle { - return angleGradient(point, constants.gradient_point1, constants.gradient_point2); + return angularGradient(point, constants.gradient_point1, constants.gradient_point2); } else if constant_gradient_type() == gradient_reflected { return 1.0 - abs(fract(positionAlongLine(constants.gradient_point1, constants.gradient_point2, point)) * 2.0 - 1.0); } else { diff --git a/src/core/COMInit.cpp b/src/core/COMInit.cpp index 8184de2d..31e68965 100644 --- a/src/core/COMInit.cpp +++ b/src/core/COMInit.cpp @@ -24,7 +24,7 @@ namespace Brisk { COMInitializer::COMInitializer() { - HRESULT result = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | ::COINIT_DISABLE_OLE1DDE); + HRESULT result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | ::COINIT_DISABLE_OLE1DDE); static_assert(sizeof(HRESULT) == sizeof(uint32_t)); this->result = static_cast(result); } diff --git a/src/core/Encoding.cpp b/src/core/Encoding.cpp index fe4f0fbc..0698d205 100644 --- a/src/core/Encoding.cpp +++ b/src/core/Encoding.cpp @@ -20,6 +20,8 @@ */ #include #include +#include +#include #include #include "utf8proc.h" @@ -130,7 +132,7 @@ inline char32_t consumeUtf8<4>(const char*& text, const char* end) { } if (static_cast(*text) > 0b11110111) { ++text; - // longer than 4 bytes + // invalid lead byte (0xF8..0xFF): not part of any UTF-8 sequence return UtfInvalid; } if (!isUtf8Continuation(text[1]) || !isUtf8Continuation(text[2]) || !isUtf8Continuation(text[3])) { @@ -143,6 +145,12 @@ inline char32_t consumeUtf8<4>(const char*& text, const char* end) { if (!isValidCodepoint(result)) { return UtfInvalid; } + // 0xF4 with continuation >= 0x90 and lead bytes 0xF5..0xF7 encode + // codepoints above U+10FFFF; the whole maximal subpart is consumed + // so that it yields a single invalid codepoint. + if (result > 0x10FFFFu) { + return UtfInvalid; + } return result > 0xFFFFu ? result : UtfOverlong; } @@ -241,6 +249,7 @@ static fn_produce utf8WriteTable[] = { /*29*/ &produceUtf8<0>, /*30*/ &produceUtf8<0>, /*31*/ &produceUtf8<0>, + /*32*/ &produceUtf8<0>, // codes >= 0x80000000 (incl. UtfInvalid/UtfOverlong/UtfTruncated) }; char32_t utfRead(const char*& text, const char* end) { @@ -251,15 +260,22 @@ char32_t utfRead(const char16_t*& text, const char16_t* end) { uint16_t ch = static_cast(*text); if ((ch & 0b11111000'00000000) != 0b11011000'00000000) return *text++; + // surrogate: 0xD800..0xDBFF = high, 0xDC00..0xDFFF = low (trailing) if (ch & 0b00000100'00000000) { - ++text; // high surrogate + ++text; // trailing surrogate in leading position return UtfInvalid; } if (text + 2 > end) { text = end; return UtfTruncated; } - char32_t result = 0x10000u + ((text[0] & 0x3FF) << 10) | (text[1] & 0x3FF); + uint16_t ch2 = static_cast(text[1]); + if ((ch2 & 0b11111100'00000000) != 0b11011100'00000000) { + // next unit is not a trailing surrogate; consume only the leading unit + ++text; + return UtfInvalid; + } + char32_t result = 0x10000u + ((ch & 0x3FF) << 10 | (ch2 & 0x3FF)); text += 2; return result; } @@ -278,9 +294,15 @@ char32_t utfRead(const char32_t*& text, const char32_t* end) { char32_t utfRead(const wchar_t*& text, const wchar_t* end) { if constexpr (sizeof(wchar_t) == sizeof(char16_t)) { - return utfRead(reinterpret_cast(text), reinterpret_cast(end)); - } else if constexpr (sizeof(wchar_t) == sizeof(char32_t)) { - return utfRead(reinterpret_cast(text), reinterpret_cast(end)); + const char16_t* p = reinterpret_cast(text); + char32_t result = utfRead(p, reinterpret_cast(end)); + text += p - reinterpret_cast(text); + return result; + } else { + const char32_t* p = reinterpret_cast(text); + char32_t result = utfRead(p, reinterpret_cast(end)); + text += p - reinterpret_cast(text); + return result; } } @@ -290,7 +312,7 @@ BRISK_INLINE static uint32_t leadingZeros(uint32_t value) { void utfWrite(char*& text, char* end, char32_t ch) { uint32_t code = static_cast(ch); - uint32_t bits = 32 - leadingZeros(code | 0x7F) & 0x1F; // ensure that code != 0 + uint32_t bits = 32 - leadingZeros(code | 0x7F); // ensures code != 0; bits in [7, 32] return utf8WriteTable[bits](text, end, ch); } @@ -301,7 +323,7 @@ void utfWrite(char16_t*& text, char16_t* end, char32_t ch) { return; } if (code <= 0xFFFF) { - *text++ = ch; + *text++ = static_cast(ch); return; } if (text + 2 > end) { @@ -309,8 +331,8 @@ void utfWrite(char16_t*& text, char16_t* end, char32_t ch) { return; } code -= 0x10000u; - *text++ = 0xD800 | ((code >> 10) & 0x3FF); - *text++ = 0xDC00 | (code & 0x3FF); + *text++ = static_cast(0xD800 | ((code >> 10) & 0x3FF)); + *text++ = static_cast(0xDC00 | (code & 0x3FF)); } void utfWrite(char32_t*& text, char32_t* end, char32_t ch) { @@ -319,9 +341,13 @@ void utfWrite(char32_t*& text, char32_t* end, char32_t ch) { void utfWrite(wchar_t*& text, wchar_t* end, char32_t ch) { if constexpr (sizeof(wchar_t) == sizeof(char16_t)) { - return utfWrite(reinterpret_cast(text), reinterpret_cast(end), ch); - } else if constexpr (sizeof(wchar_t) == sizeof(char32_t)) { - return utfWrite(reinterpret_cast(text), reinterpret_cast(end), ch); + char16_t* p = reinterpret_cast(text); + utfWrite(p, reinterpret_cast(end), ch); + text += p - reinterpret_cast(text); + } else { + char32_t* p = reinterpret_cast(text); + utfWrite(p, reinterpret_cast(end), ch); + text += p - reinterpret_cast(text); } } @@ -340,7 +366,7 @@ constexpr static size_t utfMaxElements(char32_t ch) { [[maybe_unused]] constexpr static size_t utfMaxElements(wchar_t ch) { if constexpr (sizeof(wchar_t) == sizeof(char16_t)) { return utfMaxElements(char16_t{}); - } else if constexpr (sizeof(wchar_t) == sizeof(char32_t)) { + } else { return utfMaxElements(char32_t{}); } } @@ -537,18 +563,22 @@ template std::basic_string utfNormalize(std::basic_string_view text, UtfNormalization normalization, UtfPolicy policy) { + // NOTE: the policy parameter is currently ignored (documented): utf8proc_map + // fails outright on invalid input instead of replacing/skipping it, so honoring + // the policy would require an extra cleanup pass. The input is cleaned up + // implicitly by the toUtf8 conversion (ReplaceInvalid policy). int opt = 0; if (normalization && UtfNormalization::Compose) - opt |= utf8proc_option_t::UTF8PROC_COMPOSE; + opt |= UTF8PROC_COMPOSE; if (normalization && UtfNormalization::Decompose) - opt |= utf8proc_option_t::UTF8PROC_DECOMPOSE; + opt |= UTF8PROC_DECOMPOSE; if (normalization && UtfNormalization::Compat) - opt |= utf8proc_option_t::UTF8PROC_COMPAT; + opt |= UTF8PROC_COMPAT; std::string u8 = toUtf8(text); utf8proc_uint8_t* dst = nullptr; if (utf8proc_ssize_t sz = utf8proc_map(reinterpret_cast(u8.data()), u8.size(), &dst, - utf8proc_option_t(opt)); // NOLINT(clang-analyzer-optin.core.EnumCastOutOfRange) + static_cast(opt)); sz >= 0) { std::string result(dst, dst + sz); std::free(dst); @@ -582,38 +612,42 @@ bool isAscii(std::string_view text) { } // namespace Brisk -namespace std { +namespace Brisk { + +// NOTE: defined in namespace Brisk, not namespace std (adding declarations to +// namespace std is undefined behavior per [namespace.std]). They are still found +// via ADL because Json is a Brisk type. -bool toJson(Brisk::Json& j, const std::u32string& s) { - return j.from(Brisk::utf32ToUtf8(s)); +bool toJson(Json& j, const std::u32string& s) { + return j.from(utf32ToUtf8(s)); } -bool toJson(Brisk::Json& j, const std::u16string& s) { - return j.from(Brisk::utf16ToUtf8(s)); +bool toJson(Json& j, const std::u16string& s) { + return j.from(utf16ToUtf8(s)); } -bool toJson(Brisk::Json& j, const std::wstring& s) { - return j.from(Brisk::wcsToUtf8(s)); +bool toJson(Json& j, const std::wstring& s) { + return j.from(wcsToUtf8(s)); } -bool fromJson(const Brisk::Json& j, std::u32string& s) { - return j.to(Brisk::RefAdapter{ [](const std::string& s) { - return Brisk::utf8ToUtf32(s); - }, - s }); +bool fromJson(const Json& j, std::u32string& s) { + return j.to(RefAdapter{ [](const std::string& s) { + return utf8ToUtf32(s); + }, + s }); } -bool fromJson(const Brisk::Json& j, std::u16string& s) { - return j.to(Brisk::RefAdapter{ [](const std::string& s) { - return Brisk::utf8ToUtf16(s); - }, - s }); +bool fromJson(const Json& j, std::u16string& s) { + return j.to(RefAdapter{ [](const std::string& s) { + return utf8ToUtf16(s); + }, + s }); } -bool fromJson(const Brisk::Json& j, std::wstring& s) { - return j.to(Brisk::RefAdapter{ [](const std::string& s) { - return Brisk::utf8ToWcs(s); - }, - s }); +bool fromJson(const Json& j, std::wstring& s) { + return j.to(RefAdapter{ [](const std::string& s) { + return utf8ToWcs(s); + }, + s }); } -} // namespace std +} // namespace Brisk diff --git a/src/core/Encoding_test.cpp b/src/core/Encoding_test.cpp index 1a883eb2..a9f0ffea 100644 --- a/src/core/Encoding_test.cpp +++ b/src/core/Encoding_test.cpp @@ -22,8 +22,10 @@ #include #include #include "Catch2Utils.hpp" +#include #include #include +#include #include using namespace Brisk; @@ -137,7 +139,11 @@ TEST_CASE("Encoding") { UtfValidation::Invalid); CHECK(utf8Cleanup("\xF0\x80\x80\x80", UtfPolicy::SkipInvalid) == ""); - CHECK(utf8Cleanup("_\xF0\x80\x80\x80\x00", UtfPolicy::SkipInvalid) == "_\x00"); + // NOTE: a string_view built from a literal stops at the first NUL (strlen + // semantics), so pass an explicit length to include the trailing NUL in the + // input, and compare against explicitly sized std::string objects. + CHECK(utf8Cleanup(std::string_view("_\xF0\x80\x80\x80\x00", 6), UtfPolicy::SkipInvalid) == + std::string("_\x00", 2)); CHECK(utf8Cleanup("_\xC0", UtfPolicy::SkipInvalid) == "_"); CHECK(utf16Cleanup(std::u16string_view(std::array{ 0xD800 }.data(), 1), @@ -148,7 +154,8 @@ TEST_CASE("Encoding") { CHECK(utf8Cleanup("\x80", UtfPolicy::ReplaceInvalid) == "" REPLACEMENT_CHAR_S); CHECK(utf8Cleanup("_\xC0", UtfPolicy::ReplaceInvalid) == "_" REPLACEMENT_CHAR_S); CHECK(utf8Cleanup("\xF0\x80\x80\x80", UtfPolicy::ReplaceInvalid) == "" REPLACEMENT_CHAR_S); - CHECK(utf8Cleanup("_\xF0\x80\x80\x80\x00", UtfPolicy::ReplaceInvalid) == "_" REPLACEMENT_CHAR_S "\x00"); + CHECK(utf8Cleanup(std::string_view("_\xF0\x80\x80\x80\x00", 6), UtfPolicy::ReplaceInvalid) == + std::string("_" REPLACEMENT_CHAR_S "\x00", 5)); CHECK(utf8Cleanup("", UtfPolicy::SkipInvalid) == ""); @@ -361,3 +368,128 @@ TEST_CASE("utf_normalize") { CHECK(utf32Normalize(U"\u1E9B\u0323", UtfNormalization::NFKC) == U"\u1E69"sv); CHECK(utf32Normalize(U"\u1E9B\u0323", UtfNormalization::NFKD) == U"\u0073\u0323\u0307"sv); } + +TEST_CASE("Encoding_utf8_out_of_range") { + // Codepoints above U+10FFFF must be rejected. + // Lead bytes 0xF5..0xF7 encode U+140000..U+1FFFFF; 0xF4 with continuation >= 0x90 + // encodes >= U+110000. + CHECK(utf8Validate("\xF4\x90\x80\x80") == UtfValidation::Invalid); // exactly U+110000 + CHECK(utf8Validate("\xF4\x9F\xBF\xBF") == UtfValidation::Invalid); // U+13FFFF + CHECK(utf8Validate("\xF5\x80\x80\x80") == UtfValidation::Invalid); // U+140000 + CHECK(utf8Validate("\xF7\xBF\xBF\xBF") == UtfValidation::Invalid); // U+1FFFFF + CHECK(utf8Validate("\xF8\x88\x80\x80\x80") == UtfValidation::Invalid); // invalid lead byte + + // Conversions and cleanup must treat out-of-range sequences as invalid. + CHECK(utf8Codepoints("\xF5\x80\x80\x80", UtfPolicy::ReplaceInvalid) == 1); + CHECK(utf8Codepoints("\xF5\x80\x80\x80", UtfPolicy::SkipInvalid) == 0); + CHECK(utf8ToUtf32("\xF4\x90\x80\x80"sv) == std::u32string(1, replacementChar)); + CHECK(utf8Cleanup("\xF5\x80\x80\x80", UtfPolicy::SkipInvalid) == ""); + CHECK(utf8Cleanup("\xF5\x80\x80\x80", UtfPolicy::ReplaceInvalid) == std::string(REPLACEMENT_CHAR_S)); + CHECK(utf8ToUtf16("\xF5\x80\x80\x80"sv) == std::u16string(1, char16_t(0xFFFD))); +} + +TEST_CASE("Encoding_utf16_surrogate_pairs") { + auto u16 = [](std::initializer_list il) { + return std::u16string_view(il.begin(), il.size()); + }; + + // A valid pair must still decode correctly. + CHECK(utf16Validate(u"\U0001F603") == UtfValidation::Valid); + CHECK(utf16ToUtf32(u16({0xD800, 0xDC00})) == U"\U00010000"); + CHECK(utf16ToUtf32(u16({0xDBFF, 0xDFFF})) == U"\U0010FFFF"); + + // A high surrogate followed by a non-low-surrogate must be invalid, not silently + // combined into a bogus codepoint. + CHECK(utf16Validate(u16({0xD800, 0x0041})) == UtfValidation::Invalid); // would decode as U+10041 + CHECK(utf16Validate(u16({0xD800, 0xD800})) == UtfValidation::Invalid); // would decode as U+10000 + CHECK(utf16Validate(u16({0xDBFF, 0x0041})) == UtfValidation::Invalid); // would decode as U+10FC41 + CHECK(utf16Validate(u16({0xD800, 0xE000})) == UtfValidation::Invalid); + + // Conversions must reject unpaired leading surrogates according to the policy. + CHECK(utf16ToUtf32(u16({0xD800, 0x0041})) == std::u32string(1, replacementChar) + U"A"); + CHECK(utf16ToUtf32(u16({0xDBFF, 0x0041})) == std::u32string(1, replacementChar) + U"A"); + CHECK(utf16ToUtf8(u16({0xD800, 0x0041})) == std::string(REPLACEMENT_CHAR_S) + "A"); + CHECK(utf16Codepoints(u16({0xD800, 0x0041}), UtfPolicy::ReplaceInvalid) == 2); + CHECK(utf16Codepoints(u16({0xD800, 0x0041}), UtfPolicy::SkipInvalid) == 1); + + // Lone trailing surrogate stays invalid (covered before, repeated for completeness). + CHECK(utf16Validate(u16({0xDC00, 0x0041})) == UtfValidation::Invalid); +} + +TEST_CASE("Encoding_utf_iterator") { + // Iteration over heap-allocated (non-literal) strings must not read past the end + // and must yield exactly the contained codepoints. + auto collect = [](auto iterator) { + std::vector result; + for (char32_t ch : iterator) + result.push_back(ch); + return result; + }; + + std::string u8("a\u732B\U0001F603"); + CHECK(utf8Validate(u8) == UtfValidation::Valid); + CHECK(collect(utf8Iterate(u8)) == std::vector{ U'a', 0x732B, 0x1F603 }); + + std::u16string u16(u"a\u732B\U0001F603"); + CHECK(utf16Validate(u16) == UtfValidation::Valid); + CHECK(collect(utf16Iterate(u16)) == std::vector{ U'a', 0x732B, 0x1F603 }); + + std::u32string u32(U"a\u732B\U0001F603"); + CHECK(utf32Validate(u32) == UtfValidation::Valid); + CHECK(collect(utf32Iterate(u32)) == std::vector{ U'a', 0x732B, 0x1F603 }); + + // Iterating an empty view yields nothing (no out-of-bounds read on ++). + CHECK(collect(utf8Iterate(""sv)).empty()); + CHECK(collect(utf16Iterate(u""sv)).empty()); + CHECK(collect(utf32Iterate(U""sv)).empty()); +} + +TEST_CASE("Encoding_utf_write_bounds") { + // utfWrite(char*) must not emit anything for out-of-range codepoints + // (previously it wrote a garbage 0xFF byte for values >= 0x80000000, + // including the UtfInvalid/UtfOverlong/UtfTruncated sentinels). + { + char buf[4] = {}; + char* p = buf; + utfWrite(p, buf + 4, char32_t(0xFFFFFFFFu)); // e.g. UtfInvalid == char32_t(-1) + CHECK(p == buf); + } + { + char buf[4] = {}; + char* p = buf; + utfWrite(p, buf + 4, UtfInvalid); + CHECK(p == buf); + utfWrite(p, buf + 4, UtfOverlong); + CHECK(p == buf); + utfWrite(p, buf + 4, UtfTruncated); + CHECK(p == buf); + } + { + // Utf8Character of an invalid codepoint must encode to an empty view. + Utf8Character invalid(UtfInvalid); + CHECK(std::string_view(invalid).empty()); + Utf8Character valid(U'\u732B'); + CHECK(std::string_view(valid) == "\u732B"sv); + } + + // PRECONDITION (documented, not validated for performance): utfWrite(char16_t*) + // passes lone surrogate codepoints through verbatim; callers must supply valid + // codepoints. Verify only that valid surrogate pairs are written correctly. + { + char16_t buf[2] = {}; + char16_t* p = buf; + utfWrite(p, buf + 2, U'\U0001F603'); + CHECK(p == buf + 2); + CHECK(std::u16string_view(buf, 2) == u"\U0001F603"); + } + + // PRECONDITION (documented, not validated for performance): utfWrite(char32_t*) + // writes exactly one unit and requires the caller to guarantee dest < dest_end. + { + char32_t buf[1] = {}; + char32_t* p = buf; + utfWrite(p, buf + 1, U'A'); + CHECK(p == buf + 1); + CHECK(buf[0] == U'A'); + } +} diff --git a/src/core/Geometry_test.cpp b/src/core/Geometry_test.cpp new file mode 100644 index 00000000..1f779e2a --- /dev/null +++ b/src/core/Geometry_test.cpp @@ -0,0 +1,242 @@ +/* + * Brisk + * + * Cross-platform application framework + * -------------------------------------------------------------- + * + * Copyright (C) 2025 Brisk Developers + * + * This file is part of the Brisk library. + * + * Brisk is dual-licensed under the GNU General Public License, version 2 (GPL-2.0+), + * and a commercial license. You may use, modify, and distribute this software under + * the terms of the GPL-2.0+ license if you comply with its conditions. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + * + * If you do not wish to be bound by the GPL-2.0+ license, you must purchase a commercial + * license. For commercial licensing options, please visit: https://brisklib.com + */ +#include +#include +#include "Catch2Utils.hpp" + +namespace Brisk { + +TEST_CASE("Rectangle basics") { + CHECK(Rectangle{ 0, 5, 5, 6 }.contains(Point{ 4, 5 })); + + CHECK(!Rectangle{ Point{ 2, 5 }, Size{ 5, 6 } }.empty()); + CHECK(Rectangle{ Point{ 2, 5 }, Size{ 0, 6 } }.empty()); + CHECK(Rectangle{ Point{ 2, 5 }, Size{ 5, 0 } }.empty()); + CHECK(Rectangle{ Point{ 2, 5 }, Size{ -1, 6 } }.empty()); + CHECK(Rectangle{ Point{ 2, 5 }, Size{ 5, -1 } }.empty()); + + CHECK(Rectangle{ 2, 5, 4, 10 }.union_(Rectangle{ 2, 5, 4, 10 }) == Rectangle{ 2, 5, 4, 10 }); + CHECK(Rectangle{ 2, 5, 4, 10 }.union_(Rectangle{ 3, 5, 4, 10 }) == Rectangle{ 2, 5, 4, 10 }); + + CHECK(Rectangle{ 2, 5, 10, 20 }.width() == 8); + CHECK(Rectangle{ 2, 5, 10, 20 }.height() == 15); + CHECK(Rectangle{ 2, 5, 10, 20 }.area() == 120); + CHECK(Rectangle{ 2, 5, 10, 20 }.size() == Size{ 8, 15 }); +} + +TEST_CASE("Rectangle longestSide (regression: longestSize)") { + // Regression: RectangleOf::longestSide() used to call a non-existent + // SizeOf::longestSize(), failing to compile when instantiated. + CHECK(Rectangle{ 2, 5, 10, 20 }.longestSide() == 15); + CHECK(Rectangle{ 2, 5, 10, 20 }.shortestSide() == 8); + CHECK(RectangleF{ 2, 5, 10, 20 }.longestSide() == 15.f); + CHECK(SizeF{ 3, 7 }.longestSide() == 7.f); + CHECK(SizeF{ 9, 2 }.longestSide() == 9.f); +} + +TEST_CASE("Rectangle toNormCoord (regression: division by zero)") { + // Regression: toNormCoord used to divide by (p1 - p1) == 0 producing NaNs. + const RectangleF r{ 10.f, 20.f, 30.f, 60.f }; + const PointF pt{ 20.f, 40.f }; + const PointF n = r.toNormCoord(pt); + CHECK(n.x == Catch::Approx(0.5f)); + CHECK(n.y == Catch::Approx(0.5f)); + + const PointF tl = r.toNormCoord(PointF{ 10.f, 20.f }); + CHECK(tl.x == Catch::Approx(0.f)); + CHECK(tl.y == Catch::Approx(0.f)); + + const PointF br = r.toNormCoord(PointF{ 30.f, 60.f }); + CHECK(br.x == Catch::Approx(1.f)); + CHECK(br.y == Catch::Approx(1.f)); + + // Also works for integer rectangles: normalization must happen in + // floating point, not in the integer domain. + const Rectangle ri{ 10, 20, 30, 60 }; + const PointF ni = ri.toNormCoord(Point{ 20, 40 }); + CHECK(ni.x == Catch::Approx(0.5f)); + CHECK(ni.y == Catch::Approx(0.5f)); +} + +TEST_CASE("Rectangle toNormCoord with fallback") { + const RectangleF r{ 10.f, 20.f, 30.f, 60.f }; + const PointF inside{ 20.f, 40.f }; + const PointF outside{ 0.f, 0.f }; + const PointF fallback{ -1.f, -2.f }; + + const PointF n = r.toNormCoord(inside, fallback); + CHECK(n.x == Catch::Approx(0.5f)); + CHECK(n.y == Catch::Approx(0.5f)); + + const PointF o = r.toNormCoord(outside, fallback); + CHECK(o == fallback); +} + +TEST_CASE("Rectangle applyMargin/applyPadding (regression: parameter shadowing)") { + // Regression: applyMargin(h, v)/applyPadding(h, v) had a parameter named v + // shadowing the SIMD vector member, failing to compile when instantiated. + Rectangle r{ 10, 20, 30, 60 }; + r.applyMargin(2, 3); + CHECK(r == Rectangle{ 8, 17, 32, 63 }); + + Rectangle r2{ 10, 20, 30, 60 }; + r2.applyPadding(2, 3); + CHECK(r2 == Rectangle{ 12, 23, 28, 57 }); + + // Uniform and edges-based overloads still work. + Rectangle r3{ 10, 20, 30, 60 }; + r3.applyMargin(1); + CHECK(r3 == Rectangle{ 9, 19, 31, 61 }); + + Rectangle r4{ 10, 20, 30, 60 }; + r4.applyPadding(Edges{ 2, 3, 4, 5 }); + CHECK(r4 == Rectangle{ 12, 23, 26, 55 }); + + Rectangle r5{ 10, 20, 30, 60 }; + r5.applyMargin(Edges{ 2, 3, 4, 5 }); + CHECK(r5 == Rectangle{ 8, 17, 34, 65 }); +} + +TEST_CASE("Rectangle applyStart/withStart (regression: Simd + SizeOf)") { + // Regression: applyStart/withStart used to add a Simd to a SizeOf, + // failing to compile when instantiated. + RectangleF r{ 10.f, 20.f, 30.f, 60.f }; + + RectangleF a = r; + a.applyStart(PointF{ 1.f, 2.f }); + CHECK(a == RectangleF{ 1.f, 2.f, 21.f, 42.f }); + + RectangleF b = r; + b.applyStart(1.f, 2.f); + CHECK(b == RectangleF{ 1.f, 2.f, 21.f, 42.f }); + + CHECK(r.withStart(PointF{ 1.f, 2.f }) == RectangleF{ 1.f, 2.f, 21.f, 42.f }); + CHECK(r.withStart(1.f, 2.f) == RectangleF{ 1.f, 2.f, 21.f, 42.f }); +} + +TEST_CASE("Rectangle applySize (regression: undefined pack)") { + // Regression: applySize(w, h) used an undefined helper pack(w, h), + // failing to compile when instantiated. + RectangleF r{ 10.f, 20.f, 30.f, 60.f }; + r.applySize(5.f, 8.f); + CHECK(r == RectangleF{ 10.f, 20.f, 15.f, 28.f }); + + RectangleF r2{ 10.f, 20.f, 30.f, 60.f }; + r2.applyWidth(100.f); + CHECK(r2 == RectangleF{ 10.f, 20.f, 110.f, 60.f }); + + RectangleF r3{ 10.f, 20.f, 30.f, 60.f }; + r3.applyHeight(100.f); + CHECK(r3 == RectangleF{ 10.f, 20.f, 30.f, 120.f }); +} + +TEST_CASE("Rectangle split (regression: Simd * Simd)") { + // Regression: split() multiplied Simd by Simd directly, + // failing to compile for integer rectangles. + const Rectangle ri{ 0, 0, 100, 200 }; + const Rectangle a = ri.split(0.5f, 0.5f, 0.25f, 0.25f); + CHECK(a == Rectangle{ 50, 100, 75, 150 }); + + const RectangleF rf{ 0.f, 0.f, 100.f, 200.f }; + const RectangleF b = rf.split(PointF{ 0.5f, 0.5f }, SizeF{ 0.25f, 0.25f }); + CHECK(b == RectangleF{ 50.f, 100.f, 75.f, 150.f }); +} + +TEST_CASE("Point distanceManhattan (regression: Chebyshev)") { + // Regression: distanceManhattan used horizontalMax (Chebyshev distance) + // instead of horizontalSum (Manhattan distance). + CHECK(PointF{ 0.f, 0.f }.distanceManhattan(PointF{ 3.f, 4.f }) == 7.f); + CHECK(PointF{ 0.f, 0.f }.distanceManhattan(PointF{ -3.f, 4.f }) == 7.f); + CHECK(PointF{ 1.f, 1.f }.distanceManhattan(PointF{ 1.f, 1.f }) == 0.f); + CHECK(Point{ 0, 0 }.distanceManhattan(Point{ 3, 4 }) == 7); +} + +TEST_CASE("Polar conversion") { + using Catch::Approx; + + const PolarF p = PointF(10.f, 0.f); + CHECK(p.angle == Approx(0.f).margin(0.002f)); + CHECK(p.radius == Approx(10.f).margin(0.002f)); + + // Regression: conversion operator used to be non-const, so this + // conversion on a const point failed to compile. + const PointF c{ 3.f, 4.f }; + const PolarF pc = c; + CHECK(pc.radius == Approx(5.f).margin(0.002f)); + CHECK(pc.angle == Approx(std::atan2(4.f, 3.f)).margin(0.002f)); + + const PointF back{ pc }; + CHECK(back.x == Approx(3.f).margin(0.002f)); + CHECK(back.y == Approx(4.f).margin(0.002f)); +} + +TEST_CASE("Element-wise min/max on geometry types") { + // min/max are now constrained to SimdCompatible element types and must + // work for both integer and floating-point geometry. + CHECK(min(Point{ 1, 5 }, Point{ 3, 2 }) == Point{ 1, 2 }); + CHECK(max(Point{ 1, 5 }, Point{ 3, 2 }) == Point{ 3, 5 }); + CHECK(min(PointF{ 1.f, 5.f }, PointF{ 3.f, 2.f }) == PointF{ 1.f, 2.f }); + CHECK(max(PointF{ 1.f, 5.f }, PointF{ 3.f, 2.f }) == PointF{ 3.f, 5.f }); + + CHECK(min(Size{ 1, 5 }, Size{ 3, 2 }) == Size{ 1, 2 }); + CHECK(max(Size{ 1, 5 }, Size{ 3, 2 }) == Size{ 3, 5 }); + CHECK(min(SizeF{ 1.f, 5.f }, SizeF{ 3.f, 2.f }) == SizeF{ 1.f, 2.f }); + CHECK(max(SizeF{ 1.f, 5.f }, SizeF{ 3.f, 2.f }) == SizeF{ 3.f, 5.f }); + + CHECK(min(Edges{ 1, 5, 3, 2 }, Edges{ 4, 1, 2, 3 }) == Edges{ 1, 1, 2, 2 }); + CHECK(max(Edges{ 1, 5, 3, 2 }, Edges{ 4, 1, 2, 3 }) == Edges{ 4, 5, 3, 3 }); + CHECK(min(EdgesF{ 1.f, 5.f, 3.f, 2.f }, EdgesF{ 4.f, 1.f, 2.f, 3.f }) == EdgesF{ 1.f, 1.f, 2.f, 2.f }); + CHECK(max(EdgesF{ 1.f, 5.f, 3.f, 2.f }, EdgesF{ 4.f, 1.f, 2.f, 3.f }) == EdgesF{ 4.f, 5.f, 3.f, 3.f }); +} + +TEST_CASE("Broadcast constructor is implicit") { + // Broadcast constructors remain implicit: scalar -> geometry conversions + // are used intentionally in GUI code. + STATIC_REQUIRE(std::is_constructible_v); + STATIC_REQUIRE(std::is_convertible_v); + STATIC_REQUIRE(std::is_convertible_v); + STATIC_REQUIRE(std::is_convertible_v); + STATIC_REQUIRE(std::is_convertible_v); + STATIC_REQUIRE(std::is_convertible_v); + STATIC_REQUIRE(std::is_convertible_v); + // Note: PointOf has no broadcast (single-scalar) constructor, so + // scalar -> Point conversion is not possible by design. + STATIC_REQUIRE(!std::is_convertible_v); + STATIC_REQUIRE(!std::is_convertible_v>); + + CHECK(Size{ 3 } == Size{ 3, 3 }); + CHECK(SizeF{ 3.f } == SizeF{ 3.f, 3.f }); +} + +TEST_CASE("Rectangle roundOutward/roundInward") { + CHECK(RectangleF{ 1.2f, 2.4f, 5.6f, 8.9f }.roundOutward() == Rectangle{ 1, 2, 6, 9 }); + CHECK(RectangleF{ 1.2f, 2.4f, 5.6f, 8.9f }.roundInward() == Rectangle{ 2, 3, 5, 8 }); + CHECK(Rectangle{ 1, 2, 6, 9 }.roundOutward() == Rectangle{ 1, 2, 6, 9 }); +} + +TEST_CASE("Rectangle intersection/intersects") { + CHECK(Rectangle{ 0, 0, 10, 10 }.intersects(Rectangle{ 5, 5, 15, 15 })); + CHECK(!Rectangle{ 0, 0, 10, 10 }.intersects(Rectangle{ 20, 20, 30, 30 })); + CHECK(Rectangle{ 0, 0, 10, 10 }.intersection(Rectangle{ 5, 5, 15, 15 }) == Rectangle{ 5, 5, 10, 10 }); + CHECK(Rectangle{ 0, 0, 10, 10 }.intersection(Rectangle{ 20, 20, 30, 30 }).empty()); +} + +} // namespace Brisk diff --git a/src/core/Json_test.cpp b/src/core/Json_test.cpp index e5051c1b..56b7fc2a 100644 --- a/src/core/Json_test.cpp +++ b/src/core/Json_test.cpp @@ -248,9 +248,9 @@ TEST_CASE("toMsgPack") { toBytesView("\x82\xA7" "compact\xC3\xA6schema\x00")); - CHECK(Json::fromMsgPack(toBytesView("\x82\xA7" - "compact\xC3\xA6schema\x00")) == - Json(JsonObject{ { "compact", true }, { "schema", 0 } })); + CHECK((Json::fromMsgPack(toBytesView("\x82\xA7" + "compact\xC3\xA6schema\x00")) == + Json(JsonObject{ { "compact", true }, { "schema", 0 } }))); } } // namespace Brisk diff --git a/src/graphics/CMakeLists.txt b/src/graphics/CMakeLists.txt index 48cca846..1eadd13e 100644 --- a/src/graphics/CMakeLists.txt +++ b/src/graphics/CMakeLists.txt @@ -66,7 +66,14 @@ target_sources(brisk-graphics PRIVATE ${PROJECT_SOURCE_DIR}/src/graphics/blaze/B set_property(TARGET brisk-graphics PROPERTY EXPORT_NAME Graphics) add_library(Brisk::Graphics ALIAS brisk-graphics) +# Keep the replacement text engine isolated until the Brisk adapter is ready. +# In particular, brisk-graphics must not link this target during the bootstrap +# phase, so the existing Fonts.cpp remains the only active implementation. +add_subdirectory(TextEngine) + target_link_libraries(brisk-graphics PUBLIC brisk-core) +target_link_libraries(brisk-graphics PRIVATE brisk-text-engine) +target_include_directories(brisk-graphics PRIVATE ${PROJECT_SOURCE_DIR}/src/graphics/TextEngine/include) target_include_directories(brisk-graphics PRIVATE ${PROJECT_SOURCE_DIR}/src/graphics/vector) target_include_directories(brisk-graphics PRIVATE ${PROJECT_SOURCE_DIR}/src/graphics/vector/freetype) diff --git a/src/graphics/Canvas.cpp b/src/graphics/Canvas.cpp index 655e636e..ef3bcb6e 100644 --- a/src/graphics/Canvas.cpp +++ b/src/graphics/Canvas.cpp @@ -19,15 +19,14 @@ * license. For commercial licensing options, please visit: https://brisklib.com */ #include +#include "FontInternals.hpp" #include +#include #include #include #include "RenderStateArgs.hpp" #include -#include - #include "Mask.hpp" -#include "brisk/graphics/ImageFormats.hpp" namespace Brisk { @@ -64,43 +63,6 @@ static PointF quantize(PointF pt, unsigned value) { }; } -static GeometryGlyphs glyphLayout(uint32_t& runIndex, bool& multicolor, std::optional& color, - SpriteResources& sprites, const PreparedText& prepared, - PointF offset = { 0, 0 }) { - GeometryGlyphs result; - bool first = true; - for (; runIndex < prepared.runs.size(); ++runIndex) { - const GlyphRun& run = prepared.runVisual(runIndex); - if (first) { - color = run.color; - multicolor = run.hasColor(); - first = false; - } else { - if (run.color != color || run.hasColor() != multicolor) - return result; - } - - for (const Internal::Glyph& g : run.glyphs) { - std::optional data = g.load(run); - if (data && data->sprite) { - GeometryGlyph glyphDesc; - PointF pos = g.pos + run.position + offset; - glyphDesc.rect.p1 = quantize(pos + PointF(data->offset_x, -data->offset_y), run.hscale()); - glyphDesc.rect.p2 = - glyphDesc.rect.p1 + PointF(float(data->size.width) / run.hscale(), data->size.height); - glyphDesc.sprite = static_cast(findOrAdd(sprites, data->sprite)); - glyphDesc.stride = data->size.width; - if (run.hasColor()) - glyphDesc.stride *= 4; - glyphDesc.size = data->size; - - result.push_back(std::move(glyphDesc)); - } - } - } - return result; -} - GeometryGlyphs Internal::pathLayout(SpriteResources& sprites, const RasterizedPath& path) { GeometryGlyphs result; if (path.sprite) { @@ -697,83 +659,130 @@ void Canvas::drawImage(RectangleF rect, Rc image, Matrix matrix, SamplerM FillParams{}, m_state.transform, preparedClipPath(), m_state.scissor, m_state.opacity); } -void Canvas::fillText(PointF position, const PreparedText& text) { - fillText(position, { 0, 0 }, text); -} - -void Canvas::fillText(PointF position, PointF alignment, const PreparedText& text) { - if (alignment != PointF{}) { - position -= PointF(text.bounds().size()) * alignment; - } - Paint textPaint = m_state.fillPaint; - +void Canvas::fillText(PointF position, const TextLayout& text) { + const Paint textPaint = m_state.fillPaint; SpriteResources sprites; - uint32_t runIndex = 0; - while (runIndex < text.runs.size()) { - std::optional runColor; - uint32_t oldRunIndex = runIndex; - bool multicolor = false; - GeometryGlyphs g = glyphLayout(runIndex, multicolor, runColor, sprites, text, position); - if (multicolor) - drawColorSprites( - std::move(sprites), g, - std::tuple{ - Arg::coordMatrix = m_state.transform, - Arg::subpixelMode = m_state.subpixelText ? SubpixelMode::RGB : SubpixelMode::Off, - Internal::PaintAndTransform{ Palette::white, m_state.transform, m_state.opacity }, - }); - else - drawTextSprites( - std::move(sprites), g, - std::tuple{ - Arg::coordMatrix = m_state.transform, - Arg::subpixelMode = m_state.subpixelText ? SubpixelMode::RGB : SubpixelMode::Off, - Internal::PaintAndTransform{ runColor ? *runColor : textPaint, m_state.transform, - m_state.opacity }, - }); - for (uint32_t ri = oldRunIndex; ri < runIndex; ++ri) { - const GlyphRun& run = text.runVisual(ri); - if (run.decoration != TextDecoration::None) { - run.updateRanges(); - PointF p1{ run.textHRange.min + run.position.x, run.position.y }; - PointF p2{ run.textHRange.max + run.position.x, run.position.y }; - p1 += position; - p2 += position; - - Path path; - - if (run.decoration && TextDecoration::Underline) - path.addPolyline( - std::initializer_list{ p1 + PointF{ 0.f, run.metrics.underlineOffset() }, - p2 + PointF{ 0.f, run.metrics.underlineOffset() } }); - if (run.decoration && TextDecoration::Overline) - path.addPolyline( - std::initializer_list{ p1 + PointF{ 0.f, run.metrics.overlineOffset() }, - p2 + PointF{ 0.f, run.metrics.overlineOffset() } }); - if (run.decoration && TextDecoration::LineThrough) - path.addPolyline( - std::initializer_list{ p1 + PointF{ 0.f, run.metrics.lineThroughOffset() }, - p2 + PointF{ 0.f, run.metrics.lineThroughOffset() } }); - - strokePath( - path, runColor ? *runColor : textPaint, - StrokeParams{ .capStyle = CapStyle::Flat, .strokeWidth = run.metrics.lineThickness }, - m_state.transform, preparedClipPath(), m_state.scissor, m_state.opacity); + GeometryGlyphs glyphs; + std::optional runColor; + bool multicolor = false; + + auto flush = [&] { + if (glyphs.empty()) { + sprites.clear(); + return; + } + if (multicolor) { + drawColorSprites(std::move(sprites), glyphs, + std::tuple{ Arg::coordMatrix = m_state.transform, + Arg::subpixelMode = SubpixelMode::Off, + Internal::PaintAndTransform{ Palette::white, m_state.transform, + m_state.opacity } }); + } else { + Paint paint = textPaint; + if (runColor) { + paint = ColorW(*runColor); } + drawTextSprites( + std::move(sprites), glyphs, + std::tuple{ Arg::coordMatrix = m_state.transform, + Arg::subpixelMode = m_state.subpixelText ? SubpixelMode::RGB : SubpixelMode::Off, + Internal::PaintAndTransform{ paint, m_state.transform, m_state.opacity } }); } - } + glyphs.clear(); + sprites.clear(); + }; + + std::lock_guard lock(*fonts); + Internal::forEachTextLayoutGlyph(text, position, [&](const Internal::TextLayoutGlyph& glyph) { + const bool glyphMulticolor = glyph.bitmap.color; + if (!glyphs.empty() && (glyphMulticolor != multicolor || glyph.color != runColor)) { + flush(); + } + if (glyphs.empty()) { + multicolor = glyphMulticolor; + runColor = glyph.color; + } + if (!glyph.bitmap.sprite) { + return; + } + GeometryGlyph desc; + desc.rect.p1 = + quantize(glyph.position + PointF{ float(glyph.bitmap.offsetX) / glyph.bitmap.horizontalScale, + -float(glyph.bitmap.offsetY) }, + glyph.bitmap.horizontalScale); + desc.rect.p2 = desc.rect.p1 + PointF(float(glyph.bitmap.size.width) / glyph.bitmap.horizontalScale, + glyph.bitmap.size.height); + desc.sprite = static_cast(findOrAdd(sprites, glyph.bitmap.sprite)); + desc.stride = glyph.bitmap.size.width; + desc.size = glyph.bitmap.size; + glyphs.push_back(std::move(desc)); + }); + flush(); + + Internal::forEachTextLayoutDecoration( + text, position, [&](const Internal::TextLayoutDecoration& decoration) { + Path path; + const Paint paint = decoration.color ? Paint{ ColorW(*decoration.color) } : textPaint; + const StrokeParams params{ .capStyle = CapStyle::Flat, .strokeWidth = decoration.thickness }; + if (decoration.decoration && TextDecoration::Underline) { + const std::array points{ + decoration.start + PointF{ 0.f, decoration.underlineOffset }, + decoration.end + PointF{ 0.f, decoration.underlineOffset } + }; + path.addPolyline(points); + } + if (decoration.decoration && TextDecoration::Overline) { + const std::array points{ + decoration.start + PointF{ 0.f, decoration.overlineOffset }, + decoration.end + PointF{ 0.f, decoration.overlineOffset } + }; + path.addPolyline(points); + } + if (decoration.decoration && TextDecoration::LineThrough) { + const std::array points{ + decoration.start + PointF{ 0.f, decoration.lineThroughOffset }, + decoration.end + PointF{ 0.f, decoration.lineThroughOffset } + }; + path.addPolyline(points); + } + if (!path.empty()) { + strokePath(path, paint, params, m_state.transform, preparedClipPath(), m_state.scissor, + m_state.opacity); + } + }); +} + +void Canvas::fillText(PointF position, PointF alignment, const TextLayout& text) { + const RectangleF bounds = text.bounds(); + position -= PointF{ bounds.x1, bounds.y1 } + PointF(bounds.size()) * alignment; + fillText(position, text); } void Canvas::fillText(TextWithOptions text, PointF position, PointF alignment) { - PreparedText prepared = fonts->prepare(m_state.font, text); - PointF offset = prepared.alignLines(alignment.x, alignment.y); - return fillText(position + offset, prepared); + ShapedText shapedText = fonts->shapeText(m_state.font, text); + TextLayoutOptions options; + options.maxLineWidth = 0.f; + options.alignment = alignment.x <= 0.f ? TextLayoutAlignment::Left + : alignment.x >= 1.f ? TextLayoutAlignment::Right + : TextLayoutAlignment::Center; + const TextLayout layout = shapedText.layout(options); + const RectangleF bounds = layout.bounds(); + const PointF origin = position - PointF{ bounds.x1, bounds.y1 } - PointF(bounds.size()) * alignment; + return fillText(origin, layout); } void Canvas::fillText(TextWithOptions text, RectangleF position, PointF alignment) { - PreparedText prepared = fonts->prepare(m_state.font, text); - PointF offset = prepared.alignLines(alignment.x, alignment.y); - return fillText(position.at(alignment) + offset, prepared); + ShapedText shapedText = fonts->shapeText(m_state.font, text); + TextLayoutOptions options; + options.maxLineWidth = std::max(0.f, position.width()); + options.alignment = alignment.x <= 0.f ? TextLayoutAlignment::Left + : alignment.x >= 1.f ? TextLayoutAlignment::Right + : TextLayoutAlignment::Center; + const TextLayout layout = shapedText.layout(options); + const RectangleF bounds = layout.bounds(); + const PointF anchor = position.at(alignment); + const PointF origin = anchor - PointF{ bounds.x1, bounds.y1 } - PointF(bounds.size()) * alignment; + return fillText(origin, layout); } Canvas::StateSaver Canvas::saveState() & { @@ -826,27 +835,28 @@ Canvas::ScissorSaver Canvas::saveScissor() & { return { *this }; } -void Canvas::fillTextSelection(PointF position, const PreparedText& text, Range selection) { - if (selection.distance() != 0) { - selection.min = text.characterToGrapheme(selection.min); - selection.max = text.characterToGrapheme(selection.max); - for (uint32_t gr : selection) { - uint32_t lineIndex = text.graphemeToLine(gr); - if (lineIndex == UINT32_MAX) - continue; - auto range = text.ranges[gr]; - const auto& line = text.lines[lineIndex]; - fillRect(Rectangle(position + PointF(range.min, line.baseline - line.ascDesc.ascender), - position + PointF(range.max, line.baseline + line.ascDesc.descender))); +void Canvas::fillTextSelection(PointF position, const TextLayout& text, Range selection) { + std::lock_guard lock(*fonts); + Internal::textLayoutSelectionRects(text, selection, [&](const TextSelectionRect& rect) { + if (rect.line >= text.lineCount()) { + return; } - } -} - -void Canvas::fillTextSelection(PointF position, PointF alignment, const PreparedText& text, + const TextLine line = text.line(rect.line); + const float x0 = std::round(position.x + rect.x0); + const float x1 = std::round(position.x + rect.x1); + // Distribute leading equally around the line's ink metrics so that + // adjacent selection rectangles remain contiguous. + const float halfLeading = line.leading * 0.5f; + const float y0 = std::round(position.y + line.baseline - line.ascender - halfLeading); + const float y1 = std::round(position.y + line.baseline - line.descender + halfLeading); + fillRect(RectangleF{ PointF{ x0, y0 }, PointF{ x1, y1 } }); + }); +} + +void Canvas::fillTextSelection(PointF position, PointF alignment, const TextLayout& text, Range selection) { - if (alignment != PointF{}) { - position -= PointF(text.bounds().size()) * alignment; - } + const RectangleF bounds = text.bounds(); + position -= PointF{ bounds.x1, bounds.y1 } + PointF(bounds.size()) * alignment; fillTextSelection(position, text, selection); } diff --git a/src/graphics/D3d11Renderer/fragment.hlsl b/src/graphics/D3d11Renderer/fragment.hlsl index 2cee5dca..198604cb 100644 --- a/src/graphics/D3d11Renderer/fragment.hlsl +++ b/src/graphics/D3d11Renderer/fragment.hlsl @@ -677,7 +677,7 @@ float sdfInfLine(float2 p, float2 p1, float2 p2) { return dot((p - p1), normal); } -float angleGradient(float2 p, float2 p1, float2 p2) { +float angularGradient(float2 p, float2 p1, float2 p2) { float sd = sdfInfLine(p, p1, p2); float2 n = map(normalize((p - p1)), normalize((p2 - p1))); float first = ((atan2(n.x, n.y) * 0.15915493667125701904f) + 0.75f); @@ -696,7 +696,7 @@ float gradientPositionForPoint(float2 tint_symbol_1) { } else { uint tint_symbol_68 = constant_gradient_type(); if ((tint_symbol_68 == 2u)) { - return angleGradient(tint_symbol_1, asfloat(constants[10].xy), asfloat(constants[10].zw)); + return angularGradient(tint_symbol_1, asfloat(constants[10].xy), asfloat(constants[10].zw)); } else { uint tint_symbol_69 = constant_gradient_type(); if ((tint_symbol_69 == 3u)) { diff --git a/src/graphics/Deps.cmake b/src/graphics/Deps.cmake index 140f9058..2fac8510 100644 --- a/src/graphics/Deps.cmake +++ b/src/graphics/Deps.cmake @@ -66,6 +66,20 @@ find_package(freetype CONFIG REQUIRED) target_link_libraries(${_BRISK_GRAPHICS} ${_DEP_PRIVATE} freetype) # /freetype +# >libunibreak +if (NOT TARGET unibreak AND NOT TARGET libunibreak::libunibreak) + find_package(libunibreak CONFIG REQUIRED) +endif () +target_link_libraries(${_BRISK_GRAPHICS} ${_DEP_PRIVATE} libunibreak::libunibreak) +# /libunibreak + +# >sheenbidi +if (NOT TARGET SheenBidi::SheenBidi) + find_package(SheenBidi CONFIG REQUIRED) +endif () +target_link_libraries(${_BRISK_GRAPHICS} ${_DEP_PRIVATE} SheenBidi::SheenBidi) +# /sheenbidi + # >icu find_package( ICU diff --git a/src/graphics/FontInternals.hpp b/src/graphics/FontInternals.hpp new file mode 100644 index 00000000..6c1ec3f3 --- /dev/null +++ b/src/graphics/FontInternals.hpp @@ -0,0 +1,119 @@ +/* + * Brisk + * + * Cross-platform application framework + * -------------------------------------------------------------- + * + * Copyright (C) 2025 Brisk Developers + * + * This file is part of the Brisk library. + * + * Brisk is dual-licensed under the GNU General Public License, version 2 (GPL-2.0+), + * and a commercial license. You may use, modify, and distribute this software under + * the terms of the GPL-2.0+ license if you comply with its conditions. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + * + * If you do not wish to be bound by the GPL-2.0+ license, you must purchase a commercial + * license. For commercial licensing options, please visit: https://brisklib.com + */ +#pragma once + +#include + +namespace Brisk { + +namespace Internal { + +/** + * @brief Private bridge data for rasterizing a new-engine glyph into Brisk sprites. + */ +struct TextLayoutGlyphBitmap { + Size size; + uint32_t height{}; + Rc sprite; + uint32_t glyphIndex = 0; + int offsetX = 0; + int offsetY = 0; + bool color = false; + int horizontalScale = 1; +}; + +/** @brief Renderer-neutral glyph data emitted by the private document renderer. */ +struct TextLayoutGlyph { + PointF position; + TextLayoutGlyphBitmap bitmap; + std::optional color; +}; + +/** @brief Renderer-neutral text decoration data emitted by the private document renderer. */ +struct TextLayoutDecoration { + PointF start; + PointF end; + float underlineOffset = 0.f; + float overlineOffset = 0.f; + float lineThroughOffset = 0.f; + float thickness = 0.f; + TextDecoration decoration = TextDecoration::None; + std::optional color; +}; + +enum class GlyphRenderMode : uint8_t { + Mask, + Color, +}; + +struct GlyphCacheKey { + uint64_t fontInstanceId{}; + uint32_t glyphId{}; + + bool operator==(const GlyphCacheKey&) const noexcept = default; +}; + +struct CachedGlyph { + Size size; + uint32_t height{}; + Rc sprite; + int offsetX{}; + int offsetY{}; + GlyphRenderMode renderMode = GlyphRenderMode::Mask; + int horizontalScale = 1; +}; + +struct GlyphCacheStats { + uint64_t hits{}; + uint64_t misses{}; +}; + +class GlyphCache { +public: + virtual ~GlyphCache() = default; + virtual std::optional getOrCreate(const GlyphCacheKey& key, + function_ref()> factory) = 0; + virtual GlyphCacheStats getCacheStats() const noexcept = 0; + virtual void setMemoryBudget(size_t bytes) = 0; + virtual void clear() = 0; +}; + +void loadTextLayoutGlyphRun(const ShapedText& shapedText, uint32_t preparedRunIndex, GlyphCache* glyphCache, + function_ref onGlyph); +void forEachTextLayoutGlyph(const TextLayout& layout, PointF origin, + function_ref onGlyph); +void forEachTextLayoutDecoration(const TextLayout& layout, PointF origin, + function_ref onDecoration); +void textLayoutSelectionRects(const TextLayout& layout, Range characterSelection, + function_ref onRect); +void renderPreparedDocument(Rc image, Point origin, const ShapedText& shapedText, + const TextLayout& layout); + +} // namespace Internal + +struct PimplAccessor { + template + static auto* getImpl(const Class& obj) { + return obj.m_impl.get(); + } +}; + +} // namespace Brisk diff --git a/src/graphics/Fonts.cpp b/src/graphics/Fonts.cpp index 13bd3761..72761864 100644 --- a/src/graphics/Fonts.cpp +++ b/src/graphics/Fonts.cpp @@ -21,20 +21,18 @@ #include #include #include +#include #include +#include #include -#include #include #include -#include #include +#include #include +#include -#include -#include - -#include -#include +#include "FontInternals.hpp" #include @@ -49,343 +47,640 @@ namespace Brisk { -void uncompressICUData(); +namespace { -static std::string_view freeTypeError(FT_Error err) { -#undef FTERRORS_H_ -#define FT_ERRORDEF(e, v, s) \ - case e: \ - return s; -#define FT_ERROR_START_LIST switch (err) { -#define FT_ERROR_END_LIST } -#include FT_ERRORS_H - return "(unknown)"; +TextEngine::Direction toTextLayoutDirection(TextDirection direction) { + return direction == TextDirection::RTL ? TextEngine::Direction::RightToLeft + : TextEngine::Direction::LeftToRight; } -[[noreturn]] static void handleFTErr(FT_Error err) { - throwException(EFreeType("FreeType Error: {}", freeTypeError(err))); +TextEngine::TextAlignment toTextLayoutAlignment(TextLayoutAlignment alignment) { + switch (alignment) { + case TextLayoutAlignment::Start: + return TextEngine::TextAlignment::Start; + case TextLayoutAlignment::End: + return TextEngine::TextAlignment::End; + case TextLayoutAlignment::Left: + return TextEngine::TextAlignment::Left; + case TextLayoutAlignment::Right: + return TextEngine::TextAlignment::Right; + case TextLayoutAlignment::Center: + return TextEngine::TextAlignment::Center; + } + return TextEngine::TextAlignment::Start; } -static void handleFTErrSoft(FT_Error err) { - BRISK_LOG_ERROR("FreeType Error: {}", freeTypeError(err)); +TextEngine::FontStyle toTextLayoutStyle(FontStyle style) { + return style == FontStyle::Italic ? TextEngine::FontStyle::Italic : TextEngine::FontStyle::Normal; } -#define HANDLE_FT_ERROR(expression) \ - do { \ - FT_Error error_ = expression; \ - if (error_) { \ - handleFTErr(error_); \ - } \ - } while (0) - -#define HANDLE_FT_ERROR_SOFT(expression, ...) \ - do { \ - FT_Error error_ = expression; \ - if (error_) { \ - handleFTErrSoft(error_); \ - __VA_ARGS__; \ - } \ - } while (0) +TextEngine::FontWeight toTextLayoutWeight(FontWeight weight) { + return static_cast(static_cast(weight)); +} -struct hb_buffer_deleter { - void operator()(hb_buffer_t* ptr) { - hb_buffer_destroy(ptr); - } +struct ConvertedFont { + std::vector familyNames; + std::vector features; + TextEngine::FontDef definition{}; }; -#define HRES 64 -#define HRESf 64.f -#define DPI 72 - -#define HORIZONTAL_OVERSAMPLING 64 - -using Internal::FTFixed; +ConvertedFont convertFont(const Font& source, const std::shared_ptr& database) { + ConvertedFont converted; + for (std::string_view family : split(source.fontFamily, ',')) { + family = trim(family); + if (!family.empty()) { + converted.familyNames.push_back(family); + } + } + converted.features.reserve(source.features.size()); + for (const OpenTypeFeatureFlag& feature : source.features) { + converted.features.push_back( + TextEngine::OpenTypeFeatureFlag{ static_cast(feature.feature), feature.enabled }); + } + + converted.definition = TextEngine::FontDef{ + .familyNames = converted.familyNames, + .fontSize = TextEngine::fromFloat(source.fontSize), + .style = toTextLayoutStyle(source.style), + .weight = toTextLayoutWeight(source.weight), + .lineHeight = TextEngine::kZero, + .letterSpacing = TextEngine::fromFloat(source.letterSpacing), + .wordSpacing = TextEngine::fromFloat(source.wordSpacing), + .verticalAlign = TextEngine::fromFloat(source.verticalAlign), + .features = converted.features, + .variations = {}, + .hinting = TextEngine::Hinting::Auto, + }; -inline FTFixed toFixed16(float value) { - return std::lround(value * 0x10000); + if (source.lineHeight > 0.f) { + converted.definition.lineHeight = TextEngine::fromFloat(source.fontSize * source.lineHeight); + } + return converted; } -inline FTFixed toFixed6(float value) { - return std::lround(value * 64); -} +void convertFonts(std::span source, std::span result, + const std::shared_ptr& database) { + BRISK_ASSERT(source.size() == result.size()); -inline float fromFixed16(FTFixed value) { - return static_cast(value) / 0x10000; + for (size_t i = 0; i < source.size(); ++i) { + result[i] = convertFont(source[i].font, database); + } } -inline float fromFixed6(FTFixed value) { - return static_cast(value) / 64; -} +} // namespace namespace Internal { -struct GlyphCacheKey { - FTFixed fontSize; - uint32_t glyphIndex; +namespace { + +/// LRU glyph cache bounded by the total size of retained sprite data. +class BudgetedGlyphCache final : public GlyphCache { + struct Entry { + CachedGlyph glyph; + size_t bytes{}; + std::list::iterator lru; + }; + + struct KeyHash { + size_t operator()(const GlyphCacheKey& key) const noexcept { + uint64_t hash = key.fontInstanceId; + hash ^= static_cast(key.glyphId) + 0x9E3779B97F4A7C15ull + (hash << 6) + (hash >> 2); + return static_cast(hash); + } + }; + +public: + /// Creates a cache with the specified sprite-memory budget. + explicit BudgetedGlyphCache(size_t budget = 64u * 1024u * 1024u) : m_budget(budget) {} + + std::optional getOrCreate(const GlyphCacheKey& key, + function_ref()> factory) override { + if (auto found = m_entries.find(key); found != m_entries.end()) { + ++m_hits; + m_lru.splice(m_lru.end(), m_lru, found->second.lru); + return found->second.glyph; + } + + ++m_misses; + std::optional created = factory(); + if (!created) { + return std::nullopt; + } + + const size_t bytes = + created->sprite ? static_cast(std::max(0, created->sprite->size.area())) : 0; + if (m_budget == 0 || bytes > m_budget) { + return created; + } + + while (!m_lru.empty() && m_usedBytes + bytes > m_budget) { + const GlyphCacheKey& oldest = m_lru.front(); + auto found = m_entries.find(oldest); + if (found != m_entries.end()) { + m_usedBytes -= found->second.bytes; + m_entries.erase(found); + } + m_lru.pop_front(); + } + + m_lru.push_back(key); + auto lru = std::prev(m_lru.end()); + m_entries.emplace(key, Entry{ std::move(*created), bytes, lru }); + m_usedBytes += bytes; + return m_entries.find(key)->second.glyph; + } + + GlyphCacheStats getCacheStats() const noexcept override { + return GlyphCacheStats{ m_hits, m_misses }; + } + + void setMemoryBudget(size_t bytes) override { + m_budget = bytes; + while (!m_lru.empty() && m_usedBytes > m_budget) { + const GlyphCacheKey& oldest = m_lru.front(); + auto found = m_entries.find(oldest); + if (found != m_entries.end()) { + m_usedBytes -= found->second.bytes; + m_entries.erase(found); + } + m_lru.pop_front(); + } + } + + void clear() override { + m_entries.clear(); + m_lru.clear(); + m_usedBytes = 0; + } - bool operator==(const GlyphCacheKey&) const noexcept = default; +private: + size_t m_budget{}; + size_t m_usedBytes{}; + uint64_t m_hits{}; + uint64_t m_misses{}; + std::list m_lru; + std::unordered_map m_entries; }; -static_assert(std::has_unique_object_representations_v); +} // namespace -static GlyphCacheKey glyphCacheKey(float fontSize, uint32_t glyphIndex) { - return { toFixed6(fontSize), glyphIndex }; -} +struct SharedLibraryOwner { + void* library = nullptr; -const static InclusiveRange nullRange{ HUGE_VALF, -HUGE_VALF }; + explicit SharedLibraryOwner(void* library) : library(library) {} -struct FontFace { - FontManager* manager; - FontFlags flags; - FT_Face face; - hb_font_t* hb_font; - Bytes bytes; + ~SharedLibraryOwner() { + if (library != nullptr) { + FT_Done_FreeType(static_cast(library)); + } + } +}; - bool isSvg() const noexcept { - return (flags && FontFlags::EnableColor) && FT_HAS_SVG(face); +struct TextEngineState { + std::vector> fontBlobs; + std::shared_ptr libraryOwner; + std::shared_ptr database; + std::shared_ptr glyphCache; + int hscale = 1; + + explicit TextEngineState(std::shared_ptr owner, int hscale) + : libraryOwner(std::move(owner)), hscale(hscale) { + database = TextEngine::createFontDatabase(libraryOwner, libraryOwner->library); + glyphCache = std::make_shared(); } +}; - struct GlyphDataAndTime : GlyphData { - double time; +struct PreparedRenderStyle { + Color color{}; + uint8_t hasColor = 0; + TextDecoration decoration = TextDecoration::None; +}; + +} // namespace Internal + +struct ShapedText::Impl { + std::shared_ptr state; + TextEngine::PreparedDocument prepared; + std::vector renderStyles; + TextOptions options = TextOptions::Default; + TextEngine::LayoutUnit defaultTabInterval = TextEngine::kZero; +}; + +namespace { + +ShapedParagraph convertParagraph(const TextEngine::PreparedDocument::Paragraph& paragraph) { + return { + .characterRange = paragraph.paragraphRange, + .graphemeRange = paragraph.graphemeRange, + .direction = paragraph.baseDirection == TextEngine::Direction::RightToLeft ? TextDirection::RTL + : TextDirection::LTR, }; +} - struct SizeData { - FT_Size ftSize; - FontMetrics metrics; +TextLine convertLine(const TextEngine::LayoutLine& line) { + TextLine result{ + .characterRange = line.codepointRange, + .graphemeRange = line.graphemeRange, + .direction = line.baseDirection == TextEngine::Direction::RightToLeft ? TextDirection::RTL + : TextDirection::LTR, + .end = static_cast(line.endKind), + .baseline = TextEngine::toFloat(line.baselineY), + .ascender = TextEngine::toFloat(line.ascent), + .descender = TextEngine::toFloat(line.descent), + .leading = TextEngine::toFloat(line.leading), + .width = TextEngine::toFloat(line.width), + .trimmedWidth = TextEngine::toFloat(line.trimmedWidth), }; + return result; +} - std::unordered_map cache; - std::map sizes; - FT_Fixed xHeight = 0; - FT_Fixed capHeight = 0; - int hscale = 1; - - FontFace(const FontFace&) = delete; - FontFace(FontFace&&) = delete; - FontFace& operator=(const FontFace&) = delete; - FontFace& operator=(FontFace&&) = delete; - - ~FontFace() { - clearCache(); - if (hb_font) - hb_font_destroy(hb_font); - for (auto& s : sizes) { - HANDLE_FT_ERROR_SOFT(FT_Done_Size(s.second.ftSize), continue); - } - HANDLE_FT_ERROR_SOFT(FT_Done_Face(face), return); +RectangleF convertBounds(const TextEngine::DocumentLayout& layout) { + return RectangleF{ TextEngine::toFloat(layout.horizontalTextBounds.min), + TextEngine::toFloat(layout.verticalTextBounds.min), + TextEngine::toFloat(layout.horizontalTextBounds.max), + TextEngine::toFloat(layout.verticalTextBounds.max) }; +} + +} // namespace + +static std::shared_ptr createPreparedDocument( + const std::shared_ptr& state, const TextWithOptions& text, + const std::vector& sourceFonts, std::span sourceOffsets) { + if (sourceFonts.empty()) { + throwException(EArgument("At least one font is required to prepare a document")); + } + if (sourceOffsets.size() + 1 != sourceFonts.size()) { + throwException(EArgument("The number of font offsets must be one less than the number of fonts")); } - explicit FontFace(FontManager* manager, BytesView data, bool makeCopy, FontFlags flags) - : manager(manager), flags(flags) { - if (makeCopy) { - bytes = Bytes(data.begin(), data.end()); - data = bytes; - } - HANDLE_FT_ERROR(FT_New_Memory_Face(static_cast(manager->m_ft_library), - (const FT_Byte*)data.data(), data.size(), 0, &face)); - HANDLE_FT_ERROR(FT_Select_Charmap(face, FT_ENCODING_UNICODE)); - - hscale = isSvg() ? 1 : manager->m_hscale; - - FT_Matrix matrix = { toFixed16(1.0f / HORIZONTAL_OVERSAMPLING * hscale), toFixed16(0), toFixed16(0), - toFixed16(1.0f) }; - FT_Set_Transform(face, &matrix, NULL); - TT_OS2* os2 = (TT_OS2*)FT_Get_Sfnt_Table(face, FT_SFNT_OS2); - if (os2) { - if (os2->version >= 2) { - xHeight = os2->sxHeight; - capHeight = os2->sCapHeight; - } + // TODO: Also validate finite/representable numeric Font and layout values + // before converting them to fixed-point engine units. + for (uint32_t offset : sourceOffsets) { + if (offset == 0 || offset >= text.text.size()) { + throwException(EArgument("Font offsets must be strictly inside the document")); } + } + if (!std::is_sorted(sourceOffsets.begin(), sourceOffsets.end()) || + std::adjacent_find(sourceOffsets.begin(), sourceOffsets.end()) != sourceOffsets.end()) { + throwException(EArgument("Font offsets must be sorted and unique")); + } + + auto document = std::make_shared(); + document->state = state; + document->options = text.options; + document->defaultTabInterval = TextEngine::kZero; + document->renderStyles.reserve(sourceFonts.size()); + for (const FontAndColor& sourceFont : sourceFonts) { + document->renderStyles.push_back({ sourceFont.color.value_or(Color{}), + static_cast(sourceFont.color.has_value()), + sourceFont.font.textDecoration }); + } + + std::vector converted(sourceFonts.size()); + convertFonts(sourceFonts, converted, state->database); + std::vector definitions; + definitions.reserve(converted.size()); + for (const ConvertedFont& font : converted) { + definitions.push_back(&font.definition); + } + + std::vector boundaries; + boundaries.reserve(sourceOffsets.size() + 2); + boundaries.push_back(0); + for (uint32_t offset : sourceOffsets) { + boundaries.push_back(offset); + } + boundaries.push_back(static_cast(text.text.size())); + + const std::u32string sourceText = text.text; + const TextEngine::DocumentSource source(sourceText); + const TextEngine::BaseDirection baseDirection = text.defaultDirection == TextDirection::RTL + ? TextEngine::BaseDirection::DefaultRTL + : TextEngine::BaseDirection::DefaultLTR; + document->prepared = TextEngine::prepareDocument( + source, state->database, definitions, TextEngine::BoundaryTable(boundaries), + std::span(&baseDirection, 1)); + document->defaultTabInterval = TextEngine::fromFloat(sourceFonts.front().font.tabWidth); + return document; +} + +struct TextLayout::Impl { + RectangleF bounds{}; + RectangleF trimmedBounds{}; + std::shared_ptr document; + TextEngine::DocumentLayout layout; +}; + +ShapedText::ShapedText() : m_impl(std::make_shared()) {} + +ShapedText::~ShapedText() = default; + +ShapedText::ShapedText(const ShapedText&) = default; + +ShapedText& ShapedText::operator=(const ShapedText&) = default; + +ShapedText::ShapedText(ShapedText&&) noexcept = default; + +ShapedText& ShapedText::operator=(ShapedText&&) noexcept = default; + +ShapedText::ShapedText(std::shared_ptr impl) : m_impl(std::move(impl)) {} + +bool ShapedText::empty() const noexcept { + return !m_impl || m_impl->prepared.paragraphs.empty(); +} + +uint32_t ShapedText::characterCount() const noexcept { + return m_impl ? m_impl->prepared.graphemeMap.codepointCount() : 0; +} - // just for HarfBuzz that requires FT_Size to be set - setSize(toFixed6(10)); +uint32_t ShapedText::graphemeCount() const noexcept { + return m_impl ? m_impl->prepared.graphemeMap.graphemeCount() : 0; +} + +std::span ShapedText::graphemeBoundaries() const noexcept { + return m_impl ? m_impl->prepared.graphemeMap.graphemeBoundaries() : std::span{}; +} + +size_t ShapedText::paragraphCount() const noexcept { + return m_impl ? m_impl->prepared.paragraphs.size() : 0; +} + +ShapedParagraph ShapedText::paragraph(size_t index) const noexcept { + if (!m_impl || index >= paragraphCount()) { + return {}; + } + return convertParagraph(m_impl->prepared.paragraphs[index]); +} - hb_font = hb_ft_font_create_referenced(face); +uint32_t ShapedText::characterToGrapheme(uint32_t character) const noexcept { + if (!m_impl) { + return 0; } + character = std::min(character, characterCount()); + return m_impl->prepared.graphemeMap.toGrapheme(character); +} - std::string_view familyName() const { - return face->family_name; +uint32_t ShapedText::graphemeToCharacter(uint32_t grapheme) const noexcept { + if (!m_impl) { + return 0; } + grapheme = std::min(grapheme, graphemeCount()); + return m_impl->prepared.graphemeMap.toCodepoint(grapheme); +} - std::string_view styleName() const { - return face->style_name; +Range ShapedText::graphemeToCharacters(uint32_t grapheme) const noexcept { + if (!m_impl || graphemeCount() == 0) { + return { 0, 0 }; } + grapheme = std::min(grapheme, graphemeCount() - 1); + return m_impl->prepared.graphemeMap.codepointRangeForGrapheme(grapheme); +} + +bool ShapedText::isParagraphSeparator(uint32_t grapheme) const noexcept { + return m_impl && grapheme < graphemeCount() && m_impl->prepared.graphemes[grapheme].isParagraphSeparator; +} - bool setSize(uint32_t sz) { - HANDLE_FT_ERROR(FT_Set_Char_Size(face, sz, 0, DPI * HORIZONTAL_OVERSAMPLING, DPI)); - return true; +CaretIndex ShapedText::caretFromCharacter(uint32_t character, CaretAffinity affinity) const noexcept { + return { characterToGrapheme(character), affinity }; +} + +uint32_t ShapedText::characterFromCaret(CaretIndex caret) const noexcept { + return graphemeToCharacter(caret.grapheme); +} + +TextLayout ShapedText::layout(const TextLayoutOptions& options) const { + if (!m_impl) { + return TextLayout{}; } - SizeData lookupSize(float fontSize) { - uint32_t sz = toFixed6(fontSize); + const auto& document = *m_impl; + const TextEngine::TextAlignment alignment = toTextLayoutAlignment(options.alignment); + const TextEngine::LayoutUnit maxWidth = + m_impl->options && TextOptions::SingleLine + ? TextEngine::kInfinity + : (std::isfinite(options.maxLineWidth) ? TextEngine::fromFloat(options.maxLineWidth) + : TextEngine::kInfinity); + const TextEngine::LayoutUnit indent = TextEngine::fromFloat(options.firstLineIndent); + const TextEngine::LayoutUnit tabInterval = + options.tabWidth > 0.f ? TextEngine::fromFloat(options.tabWidth) : document.defaultTabInterval; + const TextEngine::TabStops tabStops{ TextEngine::kZero, tabInterval }; + const TextEngine::DocumentLayout engineLayout = TextEngine::layoutPreparedDocument( + document.prepared, std::span(&alignment, 1), maxWidth, tabStops, + std::span(&indent, 1), + options.allowBreakAnywhere || (m_impl->options && TextOptions::WrapAnywhere)); - auto it = sizes.find(sz); - if (it == sizes.end()) { - FT_Size ftSize; - HANDLE_FT_ERROR(FT_New_Size(face, &ftSize)); - HANDLE_FT_ERROR(FT_Activate_Size(ftSize)); - if (!setSize(sz)) - return { nullptr, {} }; + auto result = std::make_shared(); + result->document = m_impl; + result->layout = engineLayout; + result->bounds = convertBounds(engineLayout); + result->trimmedBounds = RectangleF{ TextEngine::toFloat(engineLayout.horizontalTrimmedTextBounds.min), + TextEngine::toFloat(engineLayout.verticalTextBounds.min), + TextEngine::toFloat(engineLayout.horizontalTrimmedTextBounds.max), + TextEngine::toFloat(engineLayout.verticalTextBounds.max) }; + return TextLayout(std::move(result)); +} - hb_ft_font_changed(hb_font); +TextLayout::TextLayout() : m_impl(std::make_shared()) {} - float spaceAdvanceX = getGlyphAdvance(codepointToGlyph(U' ')); - FontMetrics metrics{ - fontSize, - fromFixed6(ftSize->metrics.ascender), - fromFixed6(ftSize->metrics.descender), - fromFixed6(ftSize->metrics.height), - spaceAdvanceX, - fromFixed6(ftSize->metrics.height) * 0.0625f, - xHeight * fontSize / face->units_per_EM, - capHeight * fontSize / face->units_per_EM, - }; +TextLayout::~TextLayout() = default; - it = sizes.insert(it, std::pair{ sz, SizeData{ ftSize, metrics } }); - } else { - if (it->second.ftSize != face->size) { - HANDLE_FT_ERROR(FT_Activate_Size(it->second.ftSize)); - hb_ft_font_changed(hb_font); - } - } - return it->second; +TextLayout::TextLayout(const TextLayout&) = default; + +TextLayout& TextLayout::operator=(const TextLayout&) = default; + +TextLayout::TextLayout(TextLayout&&) noexcept = default; + +TextLayout& TextLayout::operator=(TextLayout&&) noexcept = default; + +TextLayout::TextLayout(std::shared_ptr impl) : m_impl(std::move(impl)) {} + +bool TextLayout::empty() const noexcept { + return !m_impl || m_impl->layout.lines.empty(); +} + +size_t TextLayout::lineCount() const noexcept { + return m_impl ? m_impl->layout.lines.size() : 0; +} + +TextLine TextLayout::line(size_t index) const noexcept { + if (!m_impl || index >= m_impl->layout.lines.size()) { + return {}; } + return convertLine(m_impl->layout.lines[index]); +} + +RectangleF TextLayout::bounds() const noexcept { + return m_impl ? m_impl->bounds : RectangleF{}; +} - void clearCache() { - cache.clear(); +RectangleF TextLayout::trimmedBounds() const noexcept { + return m_impl ? m_impl->trimmedBounds : RectangleF{}; +} + +CaretPosition TextLayout::caretPosition(CaretIndex index) const noexcept { + if (!m_impl || !m_impl->document) { + return {}; } + const TextEngine::CaretPosition result = + TextEngine::caretPosition(m_impl->document->prepared, m_impl->layout, + { index.grapheme, index.affinity == CaretAffinity::Upstream + ? TextEngine::CaretAffinity::Upstream + : TextEngine::CaretAffinity::Downstream }); + return { TextEngine::toFloat(result.x), result.line, result.level }; +} - GlyphId codepointToGlyph(char32_t codepoint) const { - return FT_Get_Char_Index(face, (FT_ULong)(codepoint)); +size_t TextLayout::lineForCaret(CaretIndex index) const noexcept { + if (!m_impl || m_impl->layout.lines.empty() || !m_impl->document) { + return 0; } + index.grapheme = std::min(index.grapheme, m_impl->document->prepared.graphemeMap.graphemeCount()); + const TextEngine::CaretPosition position = + TextEngine::caretPosition(m_impl->document->prepared, m_impl->layout, + { index.grapheme, index.affinity == CaretAffinity::Upstream + ? TextEngine::CaretAffinity::Upstream + : TextEngine::CaretAffinity::Downstream }); + return position.line; +} - int garbageCollectCache(double maximumTime) { - int numRemoved = 0; - double time = currentTime(); - for (auto it = cache.begin(); it != cache.end();) { - if (time - it->second.time > maximumTime) { - it = cache.erase(it); - ++numRemoved; - } else { - ++it; - } - } - return numRemoved; - } - - std::optional loadGlyphCached(float fontSize, GlyphId glyphIndex) { - if (auto it = cache.find(glyphCacheKey(fontSize, glyphIndex)); it != cache.end()) { - it->second.time = currentTime(); - return it->second; - } else { - std::ignore = lookupSize(fontSize); - std::optional data = loadGlyph(glyphIndex); - if (!data.has_value()) - return std::nullopt; - - it = cache.insert(it, std::pair{ - glyphCacheKey(fontSize, glyphIndex), - GlyphDataAndTime{ static_cast(std::move(*data)), 0.f } }); - it->second.time = currentTime(); - return it->second; - } +size_t TextLayout::lineForCharacter(uint32_t character, CaretAffinity affinity) const noexcept { + if (!m_impl || !m_impl->document) { + return 0; } + character = std::min(character, m_impl->document->prepared.graphemeMap.codepointCount()); + return lineForCaret({ m_impl->document->prepared.graphemeMap.toGrapheme(character), affinity }); +} - float getGlyphAdvance(GlyphId glyphIndex) { - FT_Int32 ftFlags = FT_LOAD_DEFAULT | FT_LOAD_TARGET_LIGHT; - if (flags && FontFlags::DisableHinting) { - ftFlags |= FT_LOAD_NO_HINTING; - } else { - ftFlags |= FT_LOAD_FORCE_AUTOHINT; - } - HANDLE_FT_ERROR_SOFT(FT_Load_Glyph(face, glyphIndex, ftFlags), return 0.f); +CaretIndex TextLayout::lineBeginning(size_t line) const noexcept { + if (!m_impl || line >= m_impl->layout.lines.size()) { + return {}; + } + const auto& value = m_impl->layout.lines[line]; + return { value.graphemeRange.min, CaretAffinity::Downstream }; +} - FT_GlyphSlot slot = face->glyph; +CaretIndex TextLayout::lineEnd(size_t line) const noexcept { + if (!m_impl || line >= m_impl->layout.lines.size()) { + return {}; + } + const auto& value = m_impl->layout.lines[line]; + return { value.graphemeRange.max, CaretAffinity::Upstream }; +} - return fromFixed6(slot->advance.x) / float(hscale); +RectangleF TextLayout::caretRect(CaretIndex index, float width) const noexcept { + if (!m_impl || m_impl->layout.lines.empty()) { + return {}; } + const CaretPosition position = caretPosition(index); + if (position.line >= m_impl->layout.lines.size()) { + return {}; + } + const TextLine value = line(position.line); + const float halfLeading = value.leading * 0.5f; + return RectangleF{ position.x, value.baseline - value.ascender - halfLeading, position.x + width, + value.baseline - value.descender + halfLeading }; +} - std::optional loadGlyph(GlyphId glyphIndex) { - FT_Int32 ftFlags; - if (isSvg()) { - ftFlags = FT_LOAD_TARGET_LIGHT | FT_LOAD_SVG_ONLY | FT_LOAD_COLOR; - } else { - ftFlags = FT_LOAD_RENDER | FT_LOAD_TARGET_LIGHT; - } - if (flags && FontFlags::DisableHinting) { - ftFlags |= FT_LOAD_NO_HINTING; - } else { - ftFlags |= FT_LOAD_FORCE_AUTOHINT; - } +CaretIndex TextLayout::hitTest(PointF point) const noexcept { + if (!m_impl || !m_impl->document) { + return {}; + } + const TextEngine::CaretIndex result = + TextEngine::hitTestPoint(m_impl->document->prepared, m_impl->layout, TextEngine::fromFloat(point.x), + TextEngine::fromFloat(point.y)); + return { result.grapheme, result.affinity == TextEngine::CaretAffinity::Upstream + ? CaretAffinity::Upstream + : CaretAffinity::Downstream }; +} - FT_Error err = FT_Load_Glyph(face, glyphIndex, ftFlags); - if (err == FT_Err_Invalid_Glyph_Index || err == FT_Err_Invalid_Argument) { - return std::nullopt; - } - HANDLE_FT_ERROR(err); +CaretIndex TextLayout::hitTestLine(size_t line, float x) const noexcept { + if (!m_impl || line >= m_impl->layout.lines.size()) { + return {}; + } + const auto& value = m_impl->layout.lines[line]; + const TextEngine::CaretIndex result = TextEngine::hitTestPoint(m_impl->document->prepared, m_impl->layout, + TextEngine::fromFloat(x), value.baselineY); + return { result.grapheme, result.affinity == TextEngine::CaretAffinity::Upstream + ? CaretAffinity::Upstream + : CaretAffinity::Downstream }; +} - if (isSvg()) { - if (face->glyph->format != FT_GLYPH_FORMAT_SVG) { - BRISK_LOG_WARN("Cannot load svg glyph #{} from a SVG font {}", glyphIndex, familyName()); - return std::nullopt; - } - FT_Render_Glyph(face->glyph, FT_RENDER_MODE_NORMAL); - } +CaretIndex TextLayout::moveCaretVertically(CaretIndex current, float preferredX, + int direction) const noexcept { + if (!m_impl || m_impl->layout.lines.empty() || direction == 0) { + return current; + } + const size_t currentLine = lineForCaret(current); + const size_t targetLine = direction < 0 ? (currentLine == 0 ? 0 : currentLine - 1) + : std::min(currentLine + 1, m_impl->layout.lines.size() - 1); + return hitTestLine(targetLine, preferredX); +} - FT_GlyphSlot slot = face->glyph; - if (slot->advance.y != 0) - return std::nullopt; +void TextLayout::selectionRects(Range selection, + function_ref onRect) const { + if (!m_impl || !m_impl->document) { + return; + } + TextEngine::selectionRects(m_impl->document->prepared, m_impl->layout, + TextEngine::GraphemeRange{ selection.min, selection.max }, + [&](const TextEngine::SelectionRect& rect) { + onRect(TextSelectionRect{ rect.line, TextEngine::toFloat(rect.x0), + TextEngine::toFloat(rect.x1) }); + }); +} - GlyphData glyph; - glyph.offset_x = slot->bitmap_left / float(hscale); - glyph.offset_y = slot->bitmap_top; - glyph.size.x = slot->bitmap.width; - glyph.size.y = slot->bitmap.rows; - unsigned comp = slot->bitmap.pixel_mode == FT_PIXEL_MODE_BGRA ? 4 : 1; - glyph.sprite = makeSprite(Size(glyph.size.x * comp, glyph.size.y)); - - if (!glyph.size.empty()) { - if (slot->bitmap.pitch == glyph.size.x * comp) { - memcpy(glyph.sprite->data(), slot->bitmap.buffer, glyph.size.area() * comp); - } else { - for (int i = 0; i < glyph.size.y; ++i) { - memcpy(glyph.sprite->data() + i * glyph.size.x * comp, - slot->bitmap.buffer + i * slot->bitmap.pitch, glyph.size.x); - } - } - } - glyph.advance_x = fromFixed6(slot->advance.x) / float(hscale); - return glyph; +void TextLayout::selectionRectsByCharacter(Range selection, + function_ref onRect) const { + if (!m_impl || !m_impl->document) { + return; } -}; + const uint32_t count = m_impl->document->prepared.graphemeMap.codepointCount(); + selection.min = std::min(selection.min, count); + selection.max = std::min(selection.max, count); + if (selection.min > selection.max) { + std::swap(selection.min, selection.max); + } + selectionRects({ m_impl->document->prepared.graphemeMap.toGrapheme(selection.min), + m_impl->document->prepared.graphemeMap.toGrapheme(selection.max) }, + onRect); +} -struct Caret { - TextOptions options; - float tabStep = 1.f; - float x = 0; - float y = 0; - int t = 0; +void uncompressICUData(); - PointF pt() const { - return { x, y }; - } +static std::string_view freeTypeError(FT_Error err) { +#undef FTERRORS_H_ +#define FT_ERRORDEF(e, v, s) \ + case e: \ + return s; +#define FT_ERROR_START_LIST switch (err) { +#define FT_ERROR_END_LIST } +#include FT_ERRORS_H + return "(unknown)"; +} - void advance(char32_t ch, const GlyphData* g) { - switch (ch) { - case U'\t': - x = std::max(x, ++t * tabStep); - break; - default: - if (g) { - x += g->advance_x; - } - break; - } - } -}; +[[noreturn]] static void handleFTErr(FT_Error err) { + throwException(EFreeType("FreeType Error: {}", freeTypeError(err))); +} -} // namespace Internal +static void handleFTErrSoft(FT_Error err) { + BRISK_LOG_ERROR("FreeType Error: {}", freeTypeError(err)); +} + +#define HANDLE_FT_ERROR(expression) \ + do { \ + FT_Error error_ = expression; \ + if (error_) { \ + handleFTErr(error_); \ + } \ + } while (0) + +#define HANDLE_FT_ERROR_SOFT(expression, ...) \ + do { \ + FT_Error error_ = expression; \ + if (error_) { \ + handleFTErrSoft(error_); \ + __VA_ARGS__; \ + } \ + } while (0) using namespace Internal; @@ -525,9 +820,17 @@ SVG_RendererHooks svgHooks = { }; } // namespace -FontManager::FontManager(std::recursive_mutex* mutex, int hscale, uint32_t cacheTimeMs) - : m_lock(mutex), m_hscale(hscale), m_cacheTimeMs(cacheTimeMs) { - HANDLE_FT_ERROR(FT_Init_FreeType(&reinterpret_cast(m_ft_library))); +static void* createFreeTypeLibrary() { + FT_Library library = nullptr; + HANDLE_FT_ERROR(FT_Init_FreeType(&library)); + return library; +} + +FontManager::FontManager(std::recursive_mutex* mutex, int hscale) + : m_lock(mutex), m_ft_library(createFreeTypeLibrary()), + m_textEngine(std::make_shared( + std::make_shared(m_ft_library), hscale)), + m_hscale(hscale) { FT_Module mod = FT_Get_Module(reinterpret_cast(m_ft_library), "ot-svg"); if (!mod) { @@ -539,1393 +842,531 @@ FontManager::FontManager(std::recursive_mutex* mutex, int hscale, uint32_t cache } FontManager::~FontManager() { - m_fonts.clear(); // Free FT_Face’s before calling FT_Done_FreeType - HANDLE_FT_ERROR(FT_Done_FreeType(static_cast(m_ft_library))); + m_textEngine.reset(); } -std::vector FontManager::fontList(std::string_view ff) const { - std::vector list = split(ff, ','); - for (std::string_view& sv : list) { - sv = trim(sv); +static void loadTextLayoutGlyphRun( + const ShapedText::Impl& pdocument, uint32_t preparedRunIndex, GlyphCache* glyphCache, + function_ref onGlyph) { + if (preparedRunIndex >= pdocument.prepared.glyphRuns.size()) { + return; } - return list; -} -FontFace* FontManager::findFontByKey(FontKey fontKey) const { - for (;;) { - auto it = m_fonts.find(fontKey); - if (it != m_fonts.end()) { - return it->second.get(); - } - if (std::get<1>(fontKey) != FontStyle::Normal) - std::get<1>(fontKey) = FontStyle::Normal; - else if (std::get<2>(fontKey) != FontWeight::Regular) - std::get<2>(fontKey) = FontWeight::Regular; - else - break; + const TextEngine::GlyphRun& run = pdocument.prepared.glyphRuns[preparedRunIndex]; + if (!run.fontHandle || !pdocument.state || !pdocument.state->database) { + return; } - return nullptr; -} - -std::pair FontManager::lookupCodepoint(const Font& font, char32_t codepoint, - bool fallbackToUndef) const { - if (codepoint < U' ') - return { nullptr, UINT32_MAX }; - auto list = fontList(font.fontFamily); - for (int offset = 0; offset < list.size(); ++offset) { - if (list[offset].empty()) - continue; - FontFace* face = findFontByKey(FontKey{ list[offset], font.style, font.weight }); - if (face) { - GlyphId id = FT_Get_Char_Index(face->face, (FT_ULong)(codepoint)); - if (id != 0) - return { face, id }; - } - } - if (fallbackToUndef) { - FontFace* face = findFontByKey(FontKey{ list[0], font.style, font.weight }); - if (face) { - return { face, 0 }; - } - } - - return { nullptr, UINT32_MAX }; -} - -Internal::FontFace* FontManager::lookup(const Font& font) const { - auto list = fontList(font.fontFamily); - return findFontByKey(FontKey{ list[0], font.style, font.weight }); -} - -FontManager::FontKey FontManager::faceToKey(Internal::FontFace* face) const { - lock_quard_cond lk(m_lock); - for (const auto& f : m_fonts) { - if (face == f.second.get()) - return f.first; - } - return {}; -} - -void FontManager::addFontAlias(std::string_view newFontFamily, std::string_view existingFontFamily) { - lock_quard_cond lk(m_lock); - SmallVector>, 1> aliasesToAdd; - for (const auto& f : m_fonts) { - if (std::get<0>(f.first) == existingFontFamily) { - FontKey k = f.first; - std::get<0>(k) = newFontFamily; - aliasesToAdd.push_back(std::pair{ k, f.second }); - } - } - for (auto& f : aliasesToAdd) { - m_fonts.insert_or_assign(std::move(f.first), std::move(f.second)); - } -} - -void FontManager::addFont(std::string fontFamily, FontStyle style, FontWeight weight, BytesView data, - bool makeCopy, FontFlags flags) { - lock_quard_cond lk(m_lock); - FontKey key{ std::move(fontFamily), style, weight }; - auto fontFace = rcnew FontFace(this, data, makeCopy, flags); - m_fonts.insert_or_assign(key, fontFace); - if (fontFace->familyName() != std::get<0>(key)) { - // Register alias with real font name - m_fonts.insert_or_assign(FontKey{ fontFace->familyName(), style, weight }, std::move(fontFace)); - } -} - -status FontManager::addFontFromFile(std::string fontFamily, FontStyle style, FontWeight weight, - const fs::path& path) { - lock_quard_cond lk(m_lock); - expected b = readBytes(path); - if (b) { - addFont(std::move(fontFamily), style, weight, *b); - return {}; - } - return unexpected(b.error()); -} - -static bool cmpi(std::string_view a, std::string_view b) { - return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char a, char b) { - return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); - }); -} - -static bool isFontExt(std::string_view ext) { - return cmpi(ext, ".ttf") || cmpi(ext, ".otf"); -} - -static std::optional fontQuickInfo(FT_Library library, const fs::path& path) { - FT_Face face; - FT_Error err = FT_New_Face(library, path.string().c_str(), 0, &face); - if (err) - return std::nullopt; - SCOPE_EXIT { - FT_Done_Face(face); + const TextEngine::RasterizationOptions options{ + .horizontalScale = pdocument.state->hscale, + .enableColor = true, }; - - OsFont font; - font.path = path; - font.weight = FontWeight::Regular; - font.style = FontStyle::Normal; - if (face->family_name == nullptr) - return std::nullopt; - font.family = face->family_name; - if (face->style_name != nullptr) { - std::string_view styleName = face->style_name; - std::vector extraStyles; - for (std::string_view s : split(styleName, ' ')) { - if (cmpi(s, "Thin") || cmpi(s, "UltraLight")) { - font.weight = FontWeight::Thin; - } else if (cmpi(s, "ExtraLight")) { - font.weight = FontWeight::ExtraLight; - } else if (cmpi(s, "Light") || cmpi(s, "SemiLight")) { - font.weight = FontWeight::Light; - } else if (cmpi(s, "Regular") || cmpi(s, "Normal") || cmpi(s, "Book")) { - font.weight = FontWeight::Regular; - } else if (cmpi(s, "Medium") || cmpi(s, "Roman")) { - font.weight = FontWeight::Medium; - } else if (cmpi(s, "SemiBold") || cmpi(s, "DemiBold") || cmpi(s, "Demi")) { - font.weight = FontWeight::SemiBold; - } else if (cmpi(s, "Bold")) { - font.weight = FontWeight::Bold; - } else if (cmpi(s, "ExtraBold") || cmpi(s, "Heavy")) { - font.weight = FontWeight::ExtraBold; - } else if (cmpi(s, "Black")) { - font.weight = FontWeight::Black; - } else if (cmpi(s, "Italic") || cmpi(s, "Oblique")) { - font.style = FontStyle::Italic; - } else { - if (!s.empty()) { - extraStyles.push_back(s); - } - } - } - font.styleName = join(extraStyles, ' '); - } - - return font; -} - -std::vector FontManager::installedFonts(bool rescan) const { - lock_quard_cond lk(m_lock); - if (m_osFonts.empty() || rescan) { - m_osFonts.clear(); - for (fs::path path : fontFolders()) { - for (auto f : fs::directory_iterator(path)) { - if (f.is_regular_file() && isFontExt(f.path().extension().string())) { - if (std::optional fontInfo = - fontQuickInfo(static_cast(m_ft_library), f.path())) { - m_osFonts.push_back(std::move(*fontInfo)); - } - } - } - } - } - return m_osFonts; -} - -bool FontManager::addSystemFont(std::string fontFamily) { - lock_quard_cond lk(m_lock); - fs::path path = fontFolders().front(); -#ifdef BRISK_WINDOWS - return addFontFromFile(fontFamily, FontStyle::Normal, FontWeight::Regular, path / "segoeui.ttf") && - addFontFromFile(fontFamily, FontStyle::Italic, FontWeight::Regular, path / "segoeuii.ttf") && - addFontFromFile(fontFamily, FontStyle::Normal, FontWeight::Bold, path / "segoeuib.ttf") && - addFontFromFile(fontFamily, FontStyle::Italic, FontWeight::Bold, path / "segoeuiz.ttf"); -#elif defined BRISK_MACOS - return addFontFromFile(fontFamily, FontStyle::Normal, FontWeight::Regular, path / "SFNS.ttf") && - addFontFromFile(fontFamily, FontStyle::Italic, FontWeight::Regular, path / "SFNSItalic.ttf") && - addFontFromFile(fontFamily, FontStyle::Normal, FontWeight::Bold, path / "SFNS.ttf") && - addFontFromFile(fontFamily, FontStyle::Italic, FontWeight::Bold, path / "SFNSItalic.ttf"); -#else - return false; -#endif -} - -bool FontManager::addFontByName(std::string fontFamily, std::string_view fontName) { - lock_quard_cond lk(m_lock); - std::ignore = installedFonts(); - int num = 0; - for (const auto& f : m_osFonts) { - if (f.family == fontName && f.styleName.empty()) { - if (!addFontFromFile(fontFamily, f.style, f.weight, f.path)) - return false; - ++num; - } - } - return num > 0; -} - -std::vector FontManager::fontFamilyStyles(std::string_view font) const { - lock_quard_cond lk(m_lock); - std::vector result; - for (const auto& f : m_fonts) { - if (std::get<0>(f.first) == font) { - result.push_back(FontStyleAndWeight{ std::get<1>(f.first), std::get<2>(f.first) }); - } - } - return result; -} - -bool FontManager::hasCodepoint(const Font& font, char32_t codepoint) const { - lock_quard_cond lk(m_lock); - return lookupCodepoint(font, codepoint, false).first != nullptr; -} - -FontMetrics FontManager::metrics(const Font& font) const { - lock_quard_cond lk(m_lock); - return getMetrics(font); -} - -FontMetrics FontManager::getMetrics(const Font& font) const { - if (FontFace* ff = lookup(font)) { - if (FontFace::SizeData sz = ff->lookupSize(font.fontSize); sz.ftSize) { - return sz.metrics; - } - } - return FontMetrics{}; -} - -namespace Internal { - -// Split text into runs of the same direction -std::vector splitTextRuns(std::u32string_view text, TextDirection defaultDirection) { - std::vector textRuns; - auto bidi = bidiTextIterator(text, defaultDirection); - while (auto f = bidi->next()) { - TextRun r{ - .direction = f->direction, - .begin = f->codepointRange.min, - .end = f->codepointRange.max, - .visualOrder = f->visualOrder, - .fontIndex = 0, - .face = nullptr, - }; - textRuns.push_back(r); - } - return textRuns; -} - -template -static std::vector splitRuns(std::u32string_view text, std::vector textRuns, Fn&& fn) { - std::vector newTextRuns; - if (textRuns.empty()) - return newTextRuns; - - for (const TextRun& t : textRuns) { - if (t.end == t.begin) - continue; - auto value = fn(text[t.begin]); - uint32_t start = t.begin; - std::vector::iterator it = newTextRuns.end(); - - for (uint32_t i = t.begin + 1; i < t.end; ++i) { - auto newValue = fn(text[i]); - if (newValue != value) { - // TODO: correct t.order - it = newTextRuns.insert(it, - TextRun{ t.direction, start, i, t.visualOrder, t.fontIndex, t.face }); - if (t.direction == TextDirection::LTR) { - ++it; - } - start = i; - value = newValue; - } - } - // TODO: correct t.order - it = newTextRuns.insert(it, TextRun{ t.direction, start, t.end, t.visualOrder, t.fontIndex, t.face }); - } - - return newTextRuns; -} -} // namespace Internal - -static bool isControlCode(char32_t code) { - utf8proc_category_t cat = utf8proc_category(code); - return cat == UTF8PROC_CATEGORY_CC || cat == UTF8PROC_CATEGORY_ZL || cat == UTF8PROC_CATEGORY_ZP; -} - -// Split at control characters -std::vector FontManager::splitControls( - std::u32string_view text, const std::vector& textRuns) const { - uint32_t counter = 0; - return Internal::splitRuns(text, textRuns, [&](char32_t ch) -> char32_t { - return isControlCode(ch) ? ++counter : 0; - }); -} - -#if 0 -// Assign fonts to text runs -std::vector FontManager::assignFontsToTextRuns(std::u32string_view text, std::span fonts, - std::span offsets, - const std::vector& textRuns) const { - - return Internal::splitRuns(text, textRuns, [&](char32_t ch) -> Internal::FontFace* { - return lookupCodepoint(font, ch, true).first; - }); -} -#else -// Assign fonts to text runs -std::vector FontManager::assignFontsToTextRuns(std::u32string_view text, - std::span fonts, - std::span offsets, - const std::vector& textRuns) const { - std::vector newTextRuns; - newTextRuns.reserve(textRuns.size()); - for (const TextRun& t : textRuns) { - if (t.end == t.begin) - continue; - - const Font& font = fonts[t.fontIndex].font; - - Internal::FontFace* face = lookupCodepoint(font, text[t.begin], true).first; - uint32_t start = t.begin; - std::vector::iterator it = newTextRuns.end(); - - for (uint32_t i = t.begin + 1; i < t.end; ++i) { - char32_t codepoint = text[i]; - Internal::FontFace* newFace = lookupCodepoint(font, codepoint, true).first; - if (newFace != face) { - // TODO: correct t.visualOrder - it = newTextRuns.insert(it, - TextRun{ t.direction, start, i, t.visualOrder, t.fontIndex, face }); - if (t.direction == TextDirection::LTR) { - ++it; - } - start = i; - face = newFace; - } - } - // TODO: correct t.visualOrder - it = newTextRuns.insert(it, TextRun{ t.direction, start, t.end, t.visualOrder, t.fontIndex, face }); - } - return newTextRuns; -} -#endif - -static void fixCaret(GlyphList::iterator first, GlyphList::iterator last) { - float min_left = HUGE_VALF; - float max_right = -HUGE_VALF; - GlyphList::iterator nfirst = first; - for (; nfirst != last; ++nfirst) { - min_left = std::min(min_left, nfirst->left_caret); - max_right = std::max(max_right, nfirst->right_caret); - } - for (; first != last; ++first) { - first->left_caret = min_left; - first->right_caret = max_right; - } -} - -static constexpr uint8_t operator+(OpenTypeFeature feat) { - return static_cast(feat); -} - -static bool isPrintable(char32_t ch) { - switch (utf8proc_category(ch)) { - case UTF8PROC_CATEGORY_ZS: - case UTF8PROC_CATEGORY_ZL: - case UTF8PROC_CATEGORY_ZP: - case UTF8PROC_CATEGORY_CC: - case UTF8PROC_CATEGORY_CF: - return false; - default: - return true; - } -} - -static AscenderDescender calcAscDesc(float lineHeight, const FontMetrics& metrics) { - const float halfGap = (metrics.height * lineHeight - metrics.ascender + metrics.descender) * 0.5f; - return { metrics.ascender + halfGap, -metrics.descender + halfGap }; -} - -static AscenderDescender spanAscDesc(std::span runs) { - AscenderDescender result{ 0, 0 }; - for (const GlyphRun& r : runs) { - result = max(result, r.ascDesc()); + TextEngine::ActiveFont activeFont = pdocument.state->database->activate(run.fontHandle); + if (activeFont.ftFace == nullptr) { + return; } - return result; -} - -PreparedText FontManager::shapeRuns(const TextWithOptions& text, std::span fonts, - std::span offsets, - const std::vector& textRuns) const { - PreparedText shaped; - shaped.options = text.options; - shaped.graphemeBoundaries = textBreakPositions(text.text, TextBreakMode::Grapheme); - std::vector textBreaks; - if (!(text.options && TextOptions::SingleLine)) - textBreaks = textBreakPositions(text.text, TextBreakMode::Line); - - std::unique_ptr hb_buffer; - hb_buffer.reset(hb_buffer_create()); - - for (const TextRun& t : textRuns) { - const Font& font = fonts[t.fontIndex].font; - - PointF caret{ 0.f, 0.f }; - GlyphRun run; - run.face = t.face; - run.fontSize = font.fontSize; - run.tabWidth = font.tabWidth; - run.lineHeight = font.lineHeight; - run.visualOrder = t.visualOrder; - run.decoration = font.textDecoration; - run.direction = t.direction; - run.verticalAlign = font.verticalAlign; - run.metrics = getMetrics(font); - run.color = fonts[t.fontIndex].color; - - if (isControlCode(text.text[t.begin])) { - for (int32_t i = t.begin; i < t.end; ++i) { - Glyph g; - g.glyph = 0; - g.codepoint = text.text[i]; - g.begin_char = i; - g.end_char = i + 1; - g.dir = t.direction; - g.flags = GlyphFlags::IsControl; - run.glyphs.push_back(std::move(g)); - } - shaped.runs.push_back(std::move(run)); - shaped.visualOrder.push_back(shaped.visualOrder.size()); - run = {}; - continue; - } - if (t.face == nullptr) - continue; - - hb_buffer_reset(hb_buffer.get()); - - hb_buffer_add_codepoints(hb_buffer.get(), (const uint32_t*)text.text.data(), text.text.size(), - t.begin, t.end - t.begin); - hb_buffer_set_direction(hb_buffer.get(), - t.direction == TextDirection::LTR ? HB_DIRECTION_LTR : HB_DIRECTION_RTL); - hb_buffer_guess_segment_properties(hb_buffer.get()); - - std::vector features; - bool kernSet = false; - for (OpenTypeFeatureFlag feat : font.features) { - if (feat.feature == OpenTypeFeature::kern) { - kernSet = true; - } - features.push_back(hb_feature_t{ - openTypeFeatures[+feat.feature], - feat.enabled ? 1u : 0u, - HB_FEATURE_GLOBAL_START, - HB_FEATURE_GLOBAL_END, - }); - } - FontFlags flags = t.face->flags; - if (font.letterSpacing > 0) { - flags |= FontFlags::DisableLigatures; - } - if (!kernSet && (flags && FontFlags::DisableKerning)) { - features.push_back(hb_feature_t{ - openTypeFeatures[+OpenTypeFeature::kern], - 0u, - HB_FEATURE_GLOBAL_START, - HB_FEATURE_GLOBAL_END, - }); - } - if ((flags && FontFlags::DisableLigatures)) { - using enum OpenTypeFeature; - for (OpenTypeFeature feature : { liga, clig, dlig }) { - features.push_back(hb_feature_t{ - openTypeFeatures[+feature], - 0u, - HB_FEATURE_GLOBAL_START, - HB_FEATURE_GLOBAL_END, - }); - } - } - - std::ignore = t.face->lookupSize(font.fontSize); - - hb_shape(t.face->hb_font, hb_buffer.get(), features.data(), features.size()); - - unsigned int len = hb_buffer_get_length(hb_buffer.get()); - hb_glyph_info_t* info = hb_buffer_get_glyph_infos(hb_buffer.get(), nullptr); - hb_glyph_position_t* positions = hb_buffer_get_glyph_positions(hb_buffer.get(), nullptr); - - int run_start = run.glyphs.size(); - run.glyphs.reserve(run.glyphs.size() + len); - uint32_t cluster = UINT32_MAX; - for (uint32_t i = 0; i < len; i++) { - bool breakAllowed = - (hb_glyph_info_get_glyph_flags((info + i)) & HB_GLYPH_FLAG_UNSAFE_TO_BREAK) == 0; - bool newCluster = info[i].cluster != cluster; - if (i != 0 && newCluster) { - if (breakAllowed) { - caret.x += font.letterSpacing; - if (utf8proc_category(text.text[info[i].cluster]) == UTF8PROC_CATEGORY_ZS) { - caret.x += font.wordSpacing; + for (uint32_t preparedGlyphIndex = run.glyphRange.min; preparedGlyphIndex < run.glyphRange.max; + ++preparedGlyphIndex) { + const uint32_t glyphId = pdocument.prepared.glyphs[preparedGlyphIndex].glyphId; + + auto createGlyph = [&]() -> std::optional { + std::optional result; + std::ignore = TextEngine::rasterize( + activeFont, run.fontHandle, glyphId, options, + [&](const TextEngine::RasterizedGlyph& glyph, const uint8_t* pixels) { + const uint32_t components = glyph.bytesPerPixel; + const Size spriteSize{ static_cast(glyph.width * components), + static_cast(glyph.height) }; + Rc sprite = makeSprite(spriteSize); + const size_t rowBytes = static_cast(glyph.width) * components; + const int pitch = glyph.pitch; + const uint8_t* firstRow = pixels; + if (pitch < 0 && glyph.height > 0) { + firstRow += static_cast(-pitch) * (glyph.height - 1); } - } - } - cluster = info[i].cluster; - Glyph g; - g.glyph = info[i].codepoint; - g.codepoint = text.text[info[i].cluster]; - toggle(g.flags, GlyphFlags::IsPrintable, isPrintable(g.codepoint)); - g.pos.x = caret.x + fromFixed6(positions[i].x_offset) / HORIZONTAL_OVERSAMPLING; - g.pos.y = caret.y - fromFixed6(positions[i].y_offset); - g.left_caret = caret.x; - g.begin_char = g.end_char = info[i].cluster; - caret.x += fromFixed6(positions[i].x_advance) / HORIZONTAL_OVERSAMPLING; - g.right_caret = caret.x; - g.dir = t.direction; - bool atLineBreak = false; - if (newCluster) { - auto it = std::lower_bound(textBreaks.begin(), textBreaks.end(), info[i].cluster); - atLineBreak = it != textBreaks.end() && *it == info[i].cluster; - } - toggle(g.flags, GlyphFlags::SafeToBreak, breakAllowed); - toggle(g.flags, GlyphFlags::AtLineBreak, atLineBreak); - run.glyphs.push_back(std::move(g)); - } - uint32_t prev_begin_char = UINT32_MAX; - uint32_t end_character = t.end; - uint32_t new_end_character = t.end; - if (t.direction == TextDirection::LTR) { - for (int i = run.glyphs.size() - 1; i >= run_start; --i) { - if (run.glyphs[i].begin_char != prev_begin_char) { - end_character = new_end_character; - prev_begin_char = run.glyphs[i].begin_char; - } - run.glyphs[i].end_char = end_character; - new_end_character = run.glyphs[i].begin_char; - } - } else { - for (int i = run_start; i < run.glyphs.size(); ++i) { - if (run.glyphs[i].begin_char != prev_begin_char) { - end_character = new_end_character; - prev_begin_char = run.glyphs[i].begin_char; - } - run.glyphs[i].end_char = end_character; - new_end_character = run.glyphs[i].begin_char; - } - } - - int start = run_start; - int i; - for (i = run_start + 1; i < run.glyphs.size(); i++) { - if (run.glyphs[i - 1].begin_char != run.glyphs[i].begin_char) { - if (i - start > 1) - fixCaret(run.glyphs.begin() + start, run.glyphs.begin() + i); - start = i; - } - } - if (i - start > 1) - fixCaret(run.glyphs.begin() + start, run.glyphs.begin() + i); - - BRISK_ASSERT(!run.glyphs.empty()); - shaped.runs.push_back(std::move(run)); - shaped.visualOrder.push_back(shaped.visualOrder.size()); - run = {}; - } - shaped.lines.push_back(PreparedText::GlyphLine{ - Range{ 0u, uint32_t(shaped.runs.size()) }, - Range{ 0u, uint32_t(shaped.graphemeBoundaries.size()) }, - shaped.runs.empty() ? calcAscDesc(fonts.front().font.lineHeight, getMetrics(fonts.front().font)) - : spanAscDesc(shaped.runs), - 0.f, - }); - - return shaped; -} - -PreparedText FontManager::prepare(const Font& font, const TextWithOptions& text, float width) const { - lock_quard_cond lk(m_lock); - if (!text.richText.empty()) { - RichText richText = text.richText; - richText.setBaseFont(font); - return doPrepare(text, richText.fonts, richText.offsets, width); - } else { - return doPrepare(text, one(FontAndColor{ font }), {}, width); - } -} - -RectangleF FontManager::bounds(const Font& font, const TextWithOptions& text, - GlyphRunBounds boundsType) const { - if (!text.richText.empty()) { - RichText richText = text.richText; - richText.setBaseFont(font); - return bounds(text, richText.fonts, richText.offsets, boundsType); - } else { - return bounds(text, one(FontAndColor{ font }), {}, boundsType); - } -} - -PreparedText FontManager::prepare(const TextWithOptions& text, std::span fonts, - std::span offsets, float width) const { - BRISK_ASSERT_MSG("The number of fonts and offsets do not match", fonts.size() == offsets.size() + 1); - lock_quard_cond lk(m_lock); - return doPrepare(text, fonts, offsets, width); -} - -void FontManager::testRender(Rc image, const PreparedText& prepared, Point origin, - TestRenderFlags flags, std::initializer_list xlines, - std::initializer_list ylines) const { - lock_quard_cond lk(m_lock); - auto w = image->mapWrite(); - if (flags && TestRenderFlags::TextBounds) { - RectangleF rect; - rect = prepared.bounds(GlyphRunBounds::Alignment); - rect = rect.withOffset(origin); - for (int32_t y = std::floor(rect.y1); y < std::ceil(rect.y2); ++y) { - if (y < 0 || y >= w.height()) - continue; - PixelGreyscale8* l = w.line(y); - for (int32_t x = std::floor(rect.x1); x < std::ceil(rect.x2); ++x) { - if (x < 0 || x >= w.width()) - continue; - l[x] = std::max(l[x] - 16, 0); - } - } - } - for (int32_t x : xlines) { - for (int32_t y = 0; y < w.height(); ++y) - w.line(y)[x] = PixelGreyscale8{ 128 }; - } - for (int32_t y : ylines) { - PixelGreyscale8* l = w.line(y); - std::fill_n(l, w.width(), PixelGreyscale8{ 128 }); - } - - for (uint32_t ri = 0; ri < prepared.runs.size(); ++ri) { - const GlyphRun& run = prepared.runVisual(ri); - - for (size_t i = 0; i < run.glyphs.size(); ++i) { - const Glyph& g = run.glyphs[i]; - std::optional data = g.load(run); - - if (data && data->sprite) { - BytesView v = data->sprite->bytes(); - if (v.empty()) - continue; - for (int32_t y = 0; y < data->size.height; ++y) { - int32_t yy = std::lround(origin.y - +data->offset_y + (g.pos + run.position).y + y); - if (yy < 0 || yy >= w.height()) - continue; - PixelGreyscale8* l = w.line(yy); - for (int32_t x = 0; x < data->size.width; ++x) { - int32_t xx = std::lround(origin.x + (g.pos + run.position).x + data->offset_x + x); - if (xx < 0 || xx >= w.width()) - continue; - uint8_t value = static_cast(v[x + y * data->size.width]); - if (flags && TestRenderFlags::Fade) - value /= 2; - if (flags && TestRenderFlags::GlyphBounds) - value = std::min(value + 10, 255); - l[xx] = std::max(l[xx] - value, 0); + for (uint32_t row = 0; row < glyph.height; ++row) { + const uint8_t* source = firstRow + static_cast(row) * pitch; + std::memcpy(sprite->data() + row * rowBytes, source, rowBytes); } - } - } - } - } -} - -const size_t shapeCacheSizeLow = 190; -const size_t shapeCacheSizeHigh = 210; - -PreparedText FontManager::doShapeCached(const TextWithOptions& text, std::span fonts, - std::span offsets) const { - BRISK_ASSERT_MSG("The number of fonts and offsets do not match", fonts.size() == offsets.size() + 1); -#if 1 - // TODO: reenable cache - return doShape(text, fonts, offsets); -#else - Internal::ShapingCacheKey key{ font, text }; - ++m_cacheCounter; - if (auto it = m_shapeCache.find(key); it != m_shapeCache.end()) { - it->second.counter = m_cacheCounter; - return it->second.runs; - } else { - if (m_shapeCache.size() > shapeCacheSizeHigh) { - for (auto it = m_shapeCache.begin(); it != m_shapeCache.end();) { - if (it->second.counter < m_cacheCounter - (shapeCacheSizeHigh - shapeCacheSizeLow)) { - it = m_shapeCache.erase(it); - } else { - ++it; - } - } - } - - PreparedText shaped = doShape(text, fonts, offsets); - m_shapeCache.insert(it, std::make_pair(key, ShapeCacheEntry{ shaped, m_cacheCounter })); - return shaped; - } -#endif -} - -PreparedText FontManager::doShape(const TextWithOptions& text, std::span fonts, - std::span offsets) const { - BRISK_ASSERT_MSG("The number of fonts and offsets do not match", fonts.size() == offsets.size() + 1); - std::vector textRuns = splitTextRuns(text.text, text.defaultDirection); - - uint32_t fontIndex = 0; - for (size_t i = 0; i < textRuns.size(); ++i) { - textRuns[i].fontIndex = fontIndex; - if (!offsets.empty() && offsets.front() < textRuns[i].end) { - if (offsets.front() > textRuns[i].begin) { - textRuns.insert(textRuns.begin() + i, textRuns[i]); - textRuns[i].end = textRuns[i + 1].begin = offsets.front(); - ++fontIndex; - } - offsets = offsets.subspan(1); - } - } - - textRuns = assignFontsToTextRuns(text.text, fonts, offsets, textRuns); - textRuns = splitControls(text.text, textRuns); - return shapeRuns(text, fonts, offsets, textRuns); -} - -PreparedText FontManager::doPrepare(const TextWithOptions& text, std::span fonts, - std::span offsets, float width) const { - BRISK_ASSERT_MSG("The number of fonts and offsets do not match", fonts.size() == offsets.size() + 1); - PreparedText shaped = doShapeCached(text, fonts, offsets); - return std::move(shaped).wrap(width, text.options && TextOptions::WrapAnywhere); -} - -RectangleF FontManager::bounds(const TextWithOptions& text, std::span fonts, - std::span offsets, GlyphRunBounds boundsType) const { - BRISK_ASSERT_MSG("The number of fonts and offsets do not match", fonts.size() == offsets.size() + 1); - lock_quard_cond lk(m_lock); - PreparedText run = doPrepare(text, fonts, offsets); - return run.bounds(boundsType); -} - -void FontManager::garbageCollectCache() { - lock_quard_cond lk(m_lock); - for (auto& ff : m_fonts) { - ff.second->garbageCollectCache(0.5); - } -} - -namespace Internal { - -float Glyph::caretForDirection(bool inverse) const { - if ((dir == TextDirection::LTR) != inverse) - return right_caret; - else - return left_caret; -} - -std::optional Glyph::load(const GlyphRun& run) const { - if (!run.face || glyph == UINT32_MAX) - return std::nullopt; - return run.face->loadGlyphCached(run.fontSize, glyph); -} - -} // namespace Internal - -static RectangleF spanBounds(std::span runs, GlyphRunBounds boundsType) { - RectangleF result{ HUGE_VALF, HUGE_VALF, -HUGE_VALF, -HUGE_VALF }; - for (const GlyphRun& r : runs) { - result = result.union_(r.bounds(boundsType)); - } - return result; -} - -template -static bool isSubspanOf(std::span subspan, std::span origSpan) { - return subspan.data() >= origSpan.data() && - subspan.data() + subspan.size() <= origSpan.data() + origSpan.size(); -} - -int32_t PreparedText::yToLine(float y) const { - if (lines.empty() || y < lines.front().baseline - lines.front().ascDesc.ascender) { - return -1; - } - auto it = std::lower_bound(lines.begin(), lines.end(), y, [](const GlyphLine& line, float y) { - return line.baseline + line.ascDesc.descender < y; - }); - return std::distance(lines.begin(), it); -} - -uint32_t PreparedText::caretToGrapheme(uint32_t line, float x) const { - float distance = HUGE_VALF; - uint32_t grapheme = UINT32_MAX; - for (uint32_t i : lines[line].graphemeRange) { - float newDistance = std::abs(caretPositions[i] - x); - if (newDistance < distance) { - grapheme = i; - distance = newDistance; - } - } - return grapheme; -} - -uint32_t PreparedText::caretToGrapheme(PointF pt) const { - if (lines.empty() || !hasCaretData()) - return UINT32_MAX; - int32_t line = yToLine(pt.y); - if (line < 0) { - return 0; - } - if (line >= lines.size()) { - return graphemeBoundaries.size() - 1; - } - return caretToGrapheme(line, pt.x); -} - -PointF PreparedText::graphemeToCaret(uint32_t graphemeIndex) const { - uint32_t line = graphemeToLine(graphemeIndex); - if (line != UINT32_MAX) { - return PointF(caretPositions[graphemeIndex], lines[line].baseline); - } - return PointF{}; -} - -uint32_t PreparedText::graphemeToLine(uint32_t graphemeIndex) const { - if (lines.empty() || !hasCaretData()) - return UINT32_MAX; - auto it = std::lower_bound(lines.begin(), lines.end(), graphemeIndex, - [this](const GlyphLine& line, uint32_t graphemeIndex) { - if (line.runRange.max == 0) - return true; - uint32_t firstGrapheme = line.graphemeRange.min; - return firstGrapheme <= graphemeIndex; - }); - if (it == lines.begin()) { - return 0; - } - --it; - return std::distance(lines.begin(), it); -} - -PointF PreparedText::alignLines(float alignment_x, float alignment_y) { - if (runs.empty()) { - BRISK_ASSERT(!lines.empty()); - return { 0, -lines.front().ascDesc.height() * alignment_y + lines.front().ascDesc.ascender }; - } - RectangleF bounds = this->bounds(GlyphRunBounds::Text); - for (GlyphLine& line : lines) { - auto lineSpan = std::span{ runs }.subspan(line.runRange.min, line.runRange.distance()); - RectangleF lineBounds = spanBounds(lineSpan, GlyphRunBounds::Alignment); - for (GlyphRun& run : lineSpan) { - run.position.x = run.position.x - lineBounds.x1 - lineBounds.width() * alignment_x; - } - } - float y2 = -bounds.height() * alignment_y - bounds.y1; - return PointF(0, y2); -} - -PointF PreparedText::alignLines(PointF alignment) { - return alignLines(alignment.x, alignment.y); -} - -InclusiveRange GlyphRun::textVRange() const { -#if 0 - return { -metrics.ascender, -metrics.descender }; -#else - AscenderDescender ascDesc = this->ascDesc(); - return { -ascDesc.ascender, ascDesc.descender }; -#endif -} - -AscenderDescender GlyphRun::ascDesc() const { - BRISK_ASSERT(!glyphs.empty()); - return calcAscDesc(lineHeight, metrics); -} - -float GlyphRun::lastCaret() const noexcept { - return direction == TextDirection::LTR ? glyphs.back().right_caret : glyphs.front().left_caret; -} - -float GlyphRun::firstCaret() const noexcept { - return direction == TextDirection::LTR ? glyphs.front().left_caret : glyphs.back().right_caret; -} - -int GlyphRun::hscale() const noexcept { - return face ? face->hscale : 1; -} - -bool GlyphRun::hasColor() const noexcept { - return face && face->isSvg(); -} - -RectangleF GlyphRun::bounds(GlyphRunBounds boundsType) const { - updateRanges(); - const InclusiveRange vRange = this->textVRange(); - InclusiveRange range; - switch (boundsType) { - case GlyphRunBounds::Text: - range = textHRange; - break; - case GlyphRunBounds::Alignment: - range = alignmentHRange; - break; - case GlyphRunBounds::Printable: - range = printableHRange; - break; - default: - BRISK_UNREACHABLE(); - } - return RectangleF{ range.min, vRange.min, range.max, vRange.max }.withOffset(position); -} - -SizeF GlyphRun::size(GlyphRunBounds boundsType) const { - return bounds(boundsType).size(); -} - -Range GlyphRun::charRange() const { - BRISK_ASSERT(!glyphs.empty()); - return Range{ - std::min(glyphs.front().begin_char, glyphs.back().begin_char), - std::max(glyphs.front().end_char, glyphs.back().end_char), - }; -} - -GlyphFlags GlyphRun::flags() const { - BRISK_ASSERT(!glyphs.empty()); - return glyphs.front().flags; -} - -GlyphRun GlyphRun::breakAt(float width, bool allowEmpty, bool wrapAnywhere) & { - BRISK_ASSERT(!glyphs.empty()); - // Returns the widest glyph run that fits within the specified width - // and removes the corresponding glyphs from *this. - - Internal::GlyphList glyphs = std::move(this->glyphs); - GlyphRun result = *this; - - if (!glyphs.empty()) { - if (direction == TextDirection::LTR) { - // Handle left-to-right (LTR) text direction - // Set the maximum allowed right caret position based on the width. - float limit = glyphs.front().left_caret + width; - int breakPos = allowEmpty ? 0 : -1; - - // Find the position to break the glyph run. - for (int i = allowEmpty ? 0 : 1; i < glyphs.size(); ++i) { - // Check if the glyph has the line-break flag. - if (wrapAnywhere || (glyphs[i].flags && GlyphFlags::AtLineBreak)) { - breakPos = i; - } - // Stop if the next glyph exceeds the width and a break point has been found. - if ((glyphs[i].flags && GlyphFlags::IsPrintable) && glyphs[i].right_caret > limit && - breakPos >= 0) { - break; - } - } + result = CachedGlyph{ + .size = spriteSize, + .height = glyph.height, + .sprite = std::move(sprite), + .offsetX = glyph.left, + .offsetY = glyph.top, + .renderMode = glyph.format == TextEngine::RasterizedGlyph::Format::BGRA8 + ? GlyphRenderMode::Color + : GlyphRenderMode::Mask, + .horizontalScale = glyph.horizontalScale, + }; + }); + return result; + }; - // If no valid break position found, use the entire glyph list. - if (breakPos == -1) { - breakPos = glyphs.size(); - } + const GlyphCacheKey key{ activeFont.instanceId, glyphId }; + std::optional cached = + glyphCache ? glyphCache->getOrCreate(key, createGlyph) : createGlyph(); + if (cached) { + onGlyph(preparedGlyphIndex, Internal::TextLayoutGlyphBitmap{ + .size = cached->size, + .height = cached->height, + .sprite = cached->sprite, + .offsetX = cached->offsetX, + .offsetY = cached->offsetY, + .color = cached->renderMode == GlyphRenderMode::Color, + .horizontalScale = cached->horizontalScale, + }); + } + } +} - // If break position is zero, no splitting is needed. - if (breakPos == 0) { - this->glyphs = std::move(glyphs); - return result; - } +void Internal::loadTextLayoutGlyphRun( + const ShapedText& shapedText, uint32_t preparedRunIndex, GlyphCache* glyphCache, + function_ref onGlyph) { + const ShapedText::Impl* pdocument = PimplAccessor::getImpl(shapedText); + if (!pdocument) { + return; + } + loadTextLayoutGlyphRun(*pdocument, preparedRunIndex, glyphCache, onGlyph); +} - // Resize result glyph run to hold the glyphs that fit within the width. - result.glyphs.resize(breakPos); - std::copy_n(std::make_move_iterator(glyphs.begin()), breakPos, result.glyphs.begin()); +void Internal::forEachTextLayoutGlyph(const TextLayout& layout, PointF origin, + function_ref onGlyph) { + const TextLayout::Impl* playout = PimplAccessor::getImpl(layout); + if (!playout || !playout->document || !playout->document->state) { + return; + } - for (int i = result.glyphs.size() - 1; i >= 0; --i) { - if (utf8proc_category(result.glyphs[i].codepoint) == UTF8PROC_CATEGORY_ZS) { - result.glyphs[i].flags |= GlyphFlags::IsCompactedWhitespace; - } else { - break; + const ShapedText::Impl& document = *playout->document; + for (const TextEngine::LayoutGlyphRun& layoutRun : playout->layout.glyphRuns) { + if (layoutRun.glyphRange.empty() || + layoutRun.preparedRunIndex >= document.prepared.glyphRuns.size()) { + continue; + } + const TextEngine::GlyphRun& preparedRun = document.prepared.glyphRuns[layoutRun.preparedRunIndex]; + const Internal::PreparedRenderStyle* style = preparedRun.fontRunIndex < document.renderStyles.size() + ? &document.renderStyles[preparedRun.fontRunIndex] + : nullptr; + loadTextLayoutGlyphRun( + *(playout->document), layoutRun.preparedRunIndex, document.state->glyphCache.get(), + [&](uint32_t glyphIndex, const Internal::TextLayoutGlyphBitmap& bitmap) { + if (glyphIndex < layoutRun.glyphRange.min || glyphIndex >= layoutRun.glyphRange.max || + glyphIndex >= document.prepared.glyphs.size()) { + return; } - } + const TextEngine::Glyph& glyph = document.prepared.glyphs[glyphIndex]; + onGlyph(Internal::TextLayoutGlyph{ + .position = origin + PointF{ TextEngine::toFloat(layoutRun.xOffset + glyph.xOffset), + TextEngine::toFloat(layoutRun.yOffset + glyph.yOffset) }, + .bitmap = bitmap, + .color = style && style->hasColor ? std::optional{ style->color } : std::nullopt, + }); + }); + } +} - // Remove the processed glyphs from the original list. - glyphs.erase(glyphs.begin(), glyphs.begin() + breakPos); - this->glyphs = std::move(glyphs); - - // Adjust the positions of the remaining glyphs to maintain relative alignment. - if (!this->glyphs.empty()) { - const float offset = this->glyphs.front().left_caret; - for (auto& g : this->glyphs) { - g.left_caret -= offset; - g.right_caret -= offset; - g.pos.x -= offset; - } - } +void Internal::forEachTextLayoutDecoration( + const TextLayout& layout, PointF origin, + function_ref onDecoration) { + const TextLayout::Impl* playout = PimplAccessor::getImpl(layout); + if (!playout || !playout->document || !playout->document->state) { + return; + } - } else { - // Handle right-to-left (RTL) text direction - // Set the maximum allowed left caret position based on the width. - float limit = glyphs.back().right_caret - width; - int breakPos = allowEmpty ? 0 : -1; - - // Find the position to break the glyph run. - for (int i = allowEmpty ? 0 : 1; i < glyphs.size(); ++i) { - // Check if the glyph has the line-break flag (iterate in reverse order). - if (wrapAnywhere || (glyphs[glyphs.size() - 1 - i].flags && GlyphFlags::AtLineBreak)) { - breakPos = i; - } - // Stop if the next glyph exceeds the width and a break point has been found. - if ((glyphs[glyphs.size() - 1 - i].flags && GlyphFlags::IsPrintable) && - glyphs[glyphs.size() - 1 - i].left_caret < limit && breakPos >= 0) { - break; - } - } + const ShapedText::Impl& document = *playout->document; + for (const TextEngine::LayoutGlyphRun& layoutRun : playout->layout.glyphRuns) { + if (layoutRun.glyphRange.empty() || + layoutRun.preparedRunIndex >= document.prepared.glyphRuns.size()) { + continue; + } + const TextEngine::GlyphRun& preparedRun = document.prepared.glyphRuns[layoutRun.preparedRunIndex]; + if (preparedRun.fontRunIndex >= document.renderStyles.size()) { + continue; + } + const Internal::PreparedRenderStyle& style = document.renderStyles[preparedRun.fontRunIndex]; + if (style.decoration == TextDecoration::None) { + continue; + } - // If no valid break position found, use the entire glyph list. - if (breakPos == -1) { - breakPos = glyphs.size(); + float left = std::numeric_limits::infinity(); + float right = -std::numeric_limits::infinity(); + for (uint32_t glyphIndex = layoutRun.glyphRange.min; glyphIndex < layoutRun.glyphRange.max; + ++glyphIndex) { + if (glyphIndex >= document.prepared.glyphs.size()) { + break; } + const TextEngine::Glyph& glyph = document.prepared.glyphs[glyphIndex]; + const float glyphLeft = TextEngine::toFloat(layoutRun.xOffset + glyph.xOffset); + const float glyphRight = TextEngine::toFloat(layoutRun.xOffset + glyph.xOffset + glyph.xAdvance); + left = std::min(left, std::min(glyphLeft, glyphRight)); + right = std::max(right, std::max(glyphLeft, glyphRight)); + } + if (!std::isfinite(left) || !std::isfinite(right) || right <= left) { + continue; + } - // If break position is zero, no splitting is needed. - if (breakPos == 0) { - this->glyphs = std::move(glyphs); - return result; - } + const TextEngine::ExtendedMetrics metrics = + TextEngine::getExtendedMetrics(document.state->database.get(), preparedRun.fontHandle); + const float ascent = TextEngine::toFloat(preparedRun.metrics.ascent); + const float descent = TextEngine::toFloat(preparedRun.metrics.descent); + const float underlineOffset = metrics.underlinePosition != TextEngine::kZero + ? TextEngine::toFloat(metrics.underlinePosition) + : -descent * 0.5f; + const float overlineOffset = -ascent * 0.84375f; + const float lineThroughOffset = (underlineOffset + overlineOffset) * 0.5f; + const float thickness = metrics.lineThickness != TextEngine::kZero + ? TextEngine::toFloat(metrics.lineThickness) + : std::max(1.f, -descent * 0.125f); + onDecoration(Internal::TextLayoutDecoration{ + .start = origin + PointF{ left, TextEngine::toFloat(layoutRun.yOffset) }, + .end = origin + PointF{ right, TextEngine::toFloat(layoutRun.yOffset) }, + .underlineOffset = underlineOffset, + .overlineOffset = overlineOffset, + .lineThroughOffset = lineThroughOffset, + .thickness = thickness, + .decoration = style.decoration, + .color = style.hasColor ? std::optional{ style.color } : std::nullopt, + }); + } +} + +void Internal::textLayoutSelectionRects(const TextLayout& layout, Range characterSelection, + function_ref onRect) { + const TextLayout::Impl* playout = PimplAccessor::getImpl(layout); + if (!playout || !playout->document) { + return; + } + const ShapedText::Impl& document = *playout->document; + const uint32_t characterCount = document.prepared.graphemeMap.codepointCount(); + characterSelection.min = std::min(characterSelection.min, characterCount); + characterSelection.max = std::min(characterSelection.max, characterCount); + if (characterSelection.min >= characterSelection.max) { + return; + } + const TextEngine::GraphemeRange selection{ + document.prepared.graphemeMap.toGrapheme(characterSelection.min), + document.prepared.graphemeMap.toGrapheme(characterSelection.max), + }; + TextEngine::selectionRects(document.prepared, playout->layout, selection, + [&](const TextEngine::SelectionRect& rect) { + onRect(::Brisk::TextSelectionRect{ rect.line, TextEngine::toFloat(rect.x0), + TextEngine::toFloat(rect.x1) }); + }); +} - // Resize result glyph run to hold the glyphs that fit within the width. - result.glyphs.resize(breakPos); - std::copy_n(std::make_move_iterator(glyphs.end() - breakPos), breakPos, result.glyphs.begin()); +template +static void renderGlyphs(Pixels& pixels, Rc image, Point origin, const ShapedText& shapedText, + const TextLayout& layout, bool rgba) { + const ShapedText::Impl* pdocument = PimplAccessor::getImpl(shapedText); + const TextLayout::Impl* playout = PimplAccessor::getImpl(layout); + using PixelType = std::remove_cvref_t; + for (const TextEngine::LayoutGlyphRun& layoutRun : playout->layout.glyphRuns) { + if (layoutRun.glyphRange.empty() || + layoutRun.preparedRunIndex >= pdocument->prepared.glyphRuns.size()) { + continue; + } - for (int i = 0; i < result.glyphs.size(); ++i) { - if (utf8proc_category(result.glyphs[i].codepoint) == UTF8PROC_CATEGORY_ZS) { - result.glyphs[i].flags |= GlyphFlags::IsCompactedWhitespace; - } else { - break; + // Rasterize and blit the whole glyph run at once; the font is activated + // a single time per run inside loadTextLayoutGlyphRun. + Internal::loadTextLayoutGlyphRun( + shapedText, layoutRun.preparedRunIndex, pdocument->state->glyphCache.get(), + [&](uint32_t glyphIndex, const Internal::TextLayoutGlyphBitmap& bitmap) { + if (glyphIndex < layoutRun.glyphRange.min || glyphIndex >= layoutRun.glyphRange.max || + !bitmap.sprite || bitmap.color != rgba) { + return; } - } - - // Remove the processed glyphs from the original list. - glyphs.erase(glyphs.end() - breakPos, glyphs.end()); - this->glyphs = std::move(glyphs); - - // Adjust the positions of the resulting glyphs to maintain relative alignment. - if (!result.glyphs.empty()) { - const float offset = result.glyphs.front().left_caret; - for (auto& g : result.glyphs) { - g.left_caret -= offset; - g.right_caret -= offset; - g.pos.x -= offset; + const TextEngine::Glyph& glyph = pdocument->prepared.glyphs[glyphIndex]; + const PointF glyphOrigin{ TextEngine::toFloat(layoutRun.xOffset + glyph.xOffset) + origin.x, + TextEngine::toFloat(layoutRun.yOffset + glyph.yOffset) + origin.y }; + const PointF topLeft = glyphOrigin + PointF{ float(bitmap.offsetX) / bitmap.horizontalScale, + -float(bitmap.offsetY) }; + const int x0 = static_cast(std::floor(topLeft.x)); + const int y0 = static_cast(std::floor(topLeft.y)); + const auto source = bitmap.sprite->bytes(); + const int sourceWidth = bitmap.sprite->size.width / (bitmap.color ? 4 : 1); + const size_t sourceStride = static_cast(bitmap.sprite->size.width); + for (int y = 0; y < bitmap.height; ++y) { + const int dstY = y0 + y; + if (dstY < 0 || dstY >= image->height()) { + continue; + } + const uint8_t* src = reinterpret_cast(source.data()) + y * sourceStride; + for (int x = 0; x < sourceWidth; ++x) { + const int dstX = x0 + x; + if (dstX < 0 || dstX >= image->width()) { + continue; + } + if constexpr (std::is_same_v) { + const uint8_t srcValue = src[x]; + const uint32_t inverse = 255u - srcValue; + pixels(dstX, dstY).grey = static_cast( + srcValue + (pixels(dstX, dstY).grey * inverse + 127u) / 255u); + } else if constexpr (std::is_same_v) { + const uint8_t alpha = src[x * 4 + 3]; + auto& destination = pixels(dstX, dstY); + const uint32_t inverse = 255u - alpha; + destination.r = static_cast(src[x * 4 + 0] + + (destination.r * inverse + 127u) / 255u); + destination.g = static_cast(src[x * 4 + 1] + + (destination.g * inverse + 127u) / 255u); + destination.b = static_cast(src[x * 4 + 2] + + (destination.b * inverse + 127u) / 255u); + destination.a = + static_cast(alpha + (destination.a * inverse + 127u) / 255u); + } + } } - } - } - - // Invalidate cached ranges for both original and result glyph runs. - this->invalidateRanges(); - result.invalidateRanges(); + }); } +} - return result; +void Internal::renderPreparedDocument(Rc image, Point origin, const ShapedText& shapedText, + const TextLayout& layout) { + const ShapedText::Impl* pdocument = PimplAccessor::getImpl(shapedText); + const TextLayout::Impl* playout = PimplAccessor::getImpl(layout); + if (!image || !pdocument || !playout || playout->document.get() != pdocument) { + return; + } + const bool rgba = image->format() == ImageFormat::RGBA_U8Gamma; + if (rgba) { + auto pixels = image->mapWrite(); + renderGlyphs(pixels, image, origin, shapedText, layout, rgba); + } else { + auto pixels = image->mapWrite(); + renderGlyphs(pixels, image, origin, shapedText, layout, rgba); + } } -void GlyphRun::invalidateRanges() { - rangesValid = false; +ShapedText FontManager::shapeText(const Font& font, const TextWithOptions& text) const { + const FontAndColor fontAndColor{ font, std::nullopt }; + if (!text.richText.empty()) { + RichText richText = text.richText; + richText.setBaseFont(font); + return shapeText(text, richText.fonts, richText.offsets); + } + return shapeText(text, std::span(&fontAndColor, 1), {}); } -void GlyphRun::updateRanges() const { - if (rangesValid) - return; - textHRange = nullRange; - alignmentHRange = nullRange; - printableHRange = nullRange; - - for (int i = 0; i < glyphs.size(); ++i) { - InclusiveRange h{ glyphs[i].left_caret, glyphs[i].right_caret }; - textHRange = textHRange.union_(h); - if (!(glyphs[i].flags && GlyphFlags::IsCompactedWhitespace)) { - alignmentHRange = alignmentHRange.union_(h); - } - if (glyphs[i].flags && GlyphFlags::IsPrintable) { - printableHRange = printableHRange.union_(h); - } +ShapedText FontManager::shapeText(const TextWithOptions& text, std::span sourceFonts, + std::span offsets) const { + if (sourceFonts.empty()) { + throwException(EArgument("At least one font is required to prepare a document")); } - if (textHRange == nullRange) - textHRange = { 0.f, 0.f }; - if (alignmentHRange == nullRange) - alignmentHRange = { 0.f, 0.f }; - if (printableHRange == nullRange) - printableHRange = { 0.f, 0.f }; - rangesValid = true; + lock_quard_cond lk(m_lock); + std::vector fontsCopy(sourceFonts.begin(), sourceFonts.end()); + std::vector offsetsCopy(offsets.begin(), offsets.end()); + auto result = createPreparedDocument(m_textEngine, text, fontsCopy, offsetsCopy); + return ShapedText(std::move(result)); } -Font Font::operator()(FontWeight weight) const { - Font result = *this; - result.weight = weight; - return result; +std::vector FontManager::fontList(std::string_view ff) const { + std::vector list = split(ff, ','); + for (std::string_view& sv : list) { + sv = trim(sv); + } + return list; } -Font Font::operator()(FontStyle style) const { - Font result = *this; - result.style = style; - return result; +void FontManager::addFont(BytesView data, std::string alias) { + addFontImpl(data, std::move(alias), true); } -Font Font::operator()(float fontSize) const { - Font result = *this; - result.fontSize = fontSize; - return result; +bool FontManager::addFontFromResource(std::string resourceName, std::string alias, bool emptyOk) { + const Bytes& data = Resources::loadCached(std::move(resourceName), emptyOk); + if (data.empty()) { + return false; + } + addFontImpl(data, std::move(alias), false); + return true; } -Font Font::operator()(std::string fontFamily) const { - Font result = *this; - result.fontFamily = std::move(fontFamily); - return result; -} +void FontManager::addFontImpl(BytesView data, std::string alias, bool makeCopy) { + lock_quard_cond lk(m_lock); + if (data.empty()) { + return; + } -enum class ExtractLineResult { - NewLine, - End, - MaxWidthReached, -}; + if (makeCopy) { + m_textEngine->fontBlobs.push_back(std::make_shared(data.begin(), data.end())); + data = *m_textEngine->fontBlobs.back(); + } -/** - * @brief Extracts a line of text from the input `GlyphRuns` and populates the output. - * - * This function processes glyph runs from the `input` to fit within the specified `maxWidth`. - * It either moves entire glyph runs or splits them if necessary, considering line-breaking - * and wrapping rules. - * - * @param ascDesc Reference to `AscenderDescender` object, updated with the maximum ascender and descender - * values of the extracted line. - * @param output Reference to the `GlyphRuns` object where the extracted line will be stored. - * @param input Reference to the `GlyphRuns` object containing the remaining text to be processed. - * @param maxWidth Maximum width for the line. If `std::isinf(maxWidth)` is true, the line has no width - * constraint. - * @param wrapAnywhere Boolean flag indicating if wrapping can occur at any position within a glyph run. - */ -[[nodiscard]] static ExtractLineResult extractLine(AscenderDescender& ascDesc, GlyphRuns& output, - GlyphRuns& input, float maxWidth, bool wrapAnywhere) { - float remainingWidth = maxWidth; - bool isInf = std::isinf(maxWidth); - - bool lineIsEmpty = true; - while (!input.empty()) { - GlyphRun& run = input.front(); - ascDesc = max(ascDesc, run.ascDesc()); - - if (run.glyphs.front().codepoint == U'\n') { - input.erase(input.begin()); - return ExtractLineResult::NewLine; - } - run.updateRanges(); - - if (isInf || run.printableHRange.max <= remainingWidth) { - // The run fits on the current line - if (!isInf) - remainingWidth -= run.size(GlyphRunBounds::Text).width; - BRISK_ASSERT(!run.glyphs.empty()); - output.push_back(std::move(run)); - input.erase(input.begin()); - lineIsEmpty = false; - if (remainingWidth <= 0) - return ExtractLineResult::MaxWidthReached; - } else { - // The run doesn't fit on the current line - // Try to split run - GlyphRun partial = run.breakAt(remainingWidth, !lineIsEmpty, wrapAnywhere); - if (run.glyphs.empty()) { - input.erase(input.begin()); - } - if (partial.glyphs.empty()) { - return ExtractLineResult::MaxWidthReached; - } else { - remainingWidth -= partial.size(GlyphRunBounds::Text).width; - BRISK_ASSERT(!partial.glyphs.empty()); - output.push_back(std::move(partial)); - lineIsEmpty = false; - if (remainingWidth <= 0) - return ExtractLineResult::MaxWidthReached; - } + const std::vector registeredFamilies = m_textEngine->database->registerFont(data); + for (const std::string& family : registeredFamilies) { + if (!alias.empty() && alias != family) { + std::ignore = m_textEngine->database->addAlias(family, alias); } } - return ExtractLineResult::End; } -static void formatLine(std::span input, std::span runs, float y) { - if (input.empty()) - return; - auto& first = runs[input.front()]; - float tabWidth = first.tabWidth; - Caret caret{ .options = TextOptions::SingleLine, - .tabStep = tabWidth * runs[input.front()].metrics.spaceAdvanceX }; - - float xOffset = first.bounds(GlyphRunBounds::Text).x1 - first.bounds(GlyphRunBounds::Alignment).x1; - - for (uint32_t ri : input) { - GlyphRun& run = runs[ri]; - if (run.glyphs.front().flags && GlyphFlags::IsControl) { - // control-only runs - run.position = caret.pt() + PointF(xOffset, y); - float offset = caret.x; - for (Glyph& g : run.glyphs) { - g.left_caret = caret.pt().x - offset; - caret.advance(g.codepoint, nullptr); - g.right_caret = caret.pt().x - offset; - } - run.invalidateRanges(); - } else { - run.position = caret.pt() + PointF{ xOffset, y - run.verticalAlign }; - caret.x += run.bounds(GlyphRunBounds::Text).width(); - } +status FontManager::addFontFromFile(const fs::path& path, std::string alias) { + lock_quard_cond lk(m_lock); + expected b = readBytes(path); + if (b) { + addFont(*b, std::move(alias)); + return {}; } + return unexpected(b.error()); } -static void sortVisualOrder(std::span indices, std::span runs) { - std::stable_sort(indices.begin(), indices.end(), [runs](uint32_t a, uint32_t b) { - return runs[a].visualOrder < runs[b].visualOrder; +static bool cmpi(std::string_view a, std::string_view b) { + return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char a, char b) { + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); }); } -static bool hasControlRuns(std::span runs) { - for (const GlyphRun& gr : runs) { - if (gr.flags() && GlyphFlags::IsControl) { - return true; - } - } - return false; +static bool isFontExt(std::string_view ext) { + return cmpi(ext, ".ttf") || cmpi(ext, ".otf"); } -PreparedText PreparedText::wrap(float maxWidth, bool wrapAnywhere) && { - PreparedText result; - BRISK_ASSERT(visualOrder.size() == runs.size()); - result.graphemeBoundaries = std::move(graphemeBoundaries); - if (options && TextOptions::SingleLine || std::isinf(maxWidth) && !hasControlRuns(runs) || runs.empty()) { - result.runs = std::move(runs); - result.visualOrder.resize(result.runs.size()); - std::iota(result.visualOrder.begin(), result.visualOrder.end(), 0u); - sortVisualOrder(result.visualOrder, result.runs); - formatLine(result.visualOrder, result.runs, 0); - result.lines = std::move(lines); - } else { - float y = 0; - bool repeat = true; - AscenderDescender ascDesc{ 0, 0 }; - while (repeat) { - GlyphLine line{}; - size_t oldSize = result.runs.size(); - line.graphemeRange.min = runs.empty() ? result.graphemeBoundaries.size() - 1 - : result.characterToGrapheme(runs.front().charRange().min); - ExtractLineResult extractResult = - extractLine(line.ascDesc, result.runs, runs, maxWidth, wrapAnywhere); - line.graphemeRange.max = runs.empty() ? result.graphemeBoundaries.size() - 1 - : result.characterToGrapheme(runs.front().charRange().min); - repeat = !runs.empty() || extractResult == ExtractLineResult::NewLine; - if (!repeat) { - ++line.graphemeRange.max; - } - if (line.ascDesc.height() == 0) { - line.ascDesc = ascDesc; - } - BRISK_ASSERT(!line.graphemeRange.empty()); - if (!result.lines.empty()) - y += line.ascDesc.ascender; - line.runRange = Range{ oldSize, result.runs.size() }; - line.baseline = y; - if (result.runs.size() > oldSize) { - result.visualOrder.resize(result.runs.size()); - std::iota(result.visualOrder.begin() + oldSize, result.visualOrder.end(), - static_cast(oldSize)); - - std::span lineOrder = std::span{ result.visualOrder }.subspan(oldSize); - sortVisualOrder(lineOrder, result.runs); - formatLine(lineOrder, result.runs, line.baseline); - } - result.lines.push_back(line); - y += line.ascDesc.descender; - if (line.ascDesc.height() > 0) { - ascDesc = line.ascDesc; +static std::optional fontQuickInfo(FT_Library library, const fs::path& path) { + FT_Face face; + FT_Error err = FT_New_Face(library, path.string().c_str(), 0, &face); + if (err) + return std::nullopt; + SCOPE_EXIT { + FT_Done_Face(face); + }; + + OsFont font; + font.path = path; + font.weight = FontWeight::Regular; + font.style = FontStyle::Normal; + if (face->family_name == nullptr) + return std::nullopt; + font.family = face->family_name; + if (face->style_name != nullptr) { + std::string_view styleName = face->style_name; + std::vector extraStyles; + for (std::string_view s : split(styleName, ' ')) { + if (cmpi(s, "Thin") || cmpi(s, "UltraLight")) { + font.weight = FontWeight::Thin; + } else if (cmpi(s, "ExtraLight")) { + font.weight = FontWeight::ExtraLight; + } else if (cmpi(s, "Light") || cmpi(s, "SemiLight")) { + font.weight = FontWeight::Light; + } else if (cmpi(s, "Regular") || cmpi(s, "Normal") || cmpi(s, "Book")) { + font.weight = FontWeight::Regular; + } else if (cmpi(s, "Medium") || cmpi(s, "Roman")) { + font.weight = FontWeight::Medium; + } else if (cmpi(s, "SemiBold") || cmpi(s, "DemiBold") || cmpi(s, "Demi")) { + font.weight = FontWeight::SemiBold; + } else if (cmpi(s, "Bold")) { + font.weight = FontWeight::Bold; + } else if (cmpi(s, "ExtraBold") || cmpi(s, "Heavy")) { + font.weight = FontWeight::ExtraBold; + } else if (cmpi(s, "Black")) { + font.weight = FontWeight::Black; + } else if (cmpi(s, "Italic") || cmpi(s, "Oblique")) { + font.style = FontStyle::Italic; + } else { + if (!s.empty()) { + extraStyles.push_back(s); + } } } + font.styleName = join(extraStyles, ' '); } - return result; + return font; } -PreparedText PreparedText::wrap(float maxWidth, bool wrapAnywhere) const& { - return PreparedText(*this).wrap(maxWidth, wrapAnywhere); +std::vector FontManager::installedFonts(bool rescan) const { + lock_quard_cond lk(m_lock); + if (m_osFonts.empty() || rescan) { + m_osFonts.clear(); + for (fs::path path : fontFolders()) { + for (auto f : fs::directory_iterator(path)) { + if (f.is_regular_file() && isFontExt(f.path().extension().string())) { + if (std::optional fontInfo = + fontQuickInfo(static_cast(m_ft_library), f.path())) { + m_osFonts.push_back(std::move(*fontInfo)); + } + } + } + } + } + return m_osFonts; } -RectangleF PreparedText::bounds(GlyphRunBounds boundsType) const { - RectangleF result = spanBounds(runs, boundsType); - result.y1 = lines.front().baseline - lines.front().ascDesc.ascender; - result.y2 = lines.back().baseline + lines.back().ascDesc.descender; - return result; +bool FontManager::addSystemFont(std::string alias) { + lock_quard_cond lk(m_lock); + fs::path path = fontFolders().front(); +#ifdef BRISK_WINDOWS + return addFontFromFile(path / "segoeui.ttf", alias) && addFontFromFile(path / "segoeuii.ttf", alias) && + addFontFromFile(path / "segoeuib.ttf", alias) && addFontFromFile(path / "segoeuiz.ttf", alias); +#elif defined BRISK_MACOS + return addFontFromFile(path / "SFNS.ttf", alias) && addFontFromFile(path / "SFNSItalic.ttf", alias); +#else + return false; +#endif } -bool PreparedText::hasCaretData() const noexcept { - return !caretPositions.empty(); +bool FontManager::addFontByName(std::string_view fontName, std::string alias) { + lock_quard_cond lk(m_lock); + std::ignore = installedFonts(); + int num = 0; + for (const auto& f : m_osFonts) { + if (f.family == fontName && f.styleName.empty()) { + if (!addFontFromFile(f.path, alias)) + return false; + ++num; + } + } + return num > 0; } -void PreparedText::updateCaretData() { - BRISK_ASSERT(graphemeBoundaries.size() >= 1); - caretPositions.clear(); - ranges.clear(); +FontMetrics FontManager::metrics(const Font& font) const { + lock_quard_cond lk(m_lock); + return getMetrics(font); +} - caretPositions.resize(graphemeBoundaries.size(), NAN); - ranges.resize(graphemeBoundaries.size() - 1, { 0.f, 0.f }); - if (graphemeBoundaries.size() == 1) { - caretPositions.front() = 0; - return; - } - for (int32_t line = lines.size() - 1; line >= 0; --line) { - if (lines[line].runRange.empty()) { - for (uint32_t caret : lines[line].graphemeRange) { - if (std::isnan(caretPositions[caret])) { - caretPositions[caret] = 0; - } - } - continue; - } - for (uint32_t ri : lines[line].runRange) { - const GlyphRun& run = runs[ri]; - for (uint32_t gi = 0; gi < run.glyphs.size(); ++gi) { - const Glyph& g = run.glyphs[gi]; - uint32_t firstGrapheme = characterToGrapheme(g.begin_char); - uint32_t lastGrapheme = characterToGrapheme(g.end_char - 1); - float beginCaret = g.caretForDirection(true); - float endCaret = g.caretForDirection(false); - uint32_t numGraphemes = lastGrapheme - firstGrapheme + 1; - for (uint32_t grapheme = firstGrapheme; grapheme <= lastGrapheme; ++grapheme) { - float leftFrac = float(grapheme - firstGrapheme) / numGraphemes; - float rightFrac = float(grapheme - firstGrapheme + 1) / numGraphemes; - if (std::isnan(caretPositions[grapheme])) - caretPositions[grapheme] = mix(leftFrac, beginCaret, endCaret) + run.position.x; - if (std::isnan(caretPositions[grapheme + 1])) - caretPositions[grapheme + 1] = mix(rightFrac, beginCaret, endCaret) + run.position.x; - - ranges[grapheme].min = mix(leftFrac, g.left_caret, g.right_caret) + run.position.x; - ranges[grapheme].max = mix(rightFrac, g.left_caret, g.right_caret) + run.position.x; - } - } - } - } - float prev = 0; - for (size_t i = 0; i < caretPositions.size(); ++i) { - if (std::isnan(caretPositions[i])) - caretPositions[i] = prev; - else - prev = caretPositions[i]; - } +FontMetrics FontManager::getMetrics(const Font& font) const { + ConvertedFont cvtFont = convertFont(font, m_textEngine->database); + auto fontHandle = m_textEngine->database->resolveFont(cvtFont.definition); + TextEngine::VerticalMetrics metrics = + TextEngine::getVerticalMetrics(m_textEngine->database.get(), fontHandle); + TextEngine::ExtendedMetrics extendedMetrics = + TextEngine::getExtendedMetrics(m_textEngine->database.get(), fontHandle); + return FontMetrics{ + .size = font.fontSize, + .ascender = TextEngine::toFloat(metrics.ascent), + .descender = TextEngine::toFloat(metrics.descent), + .height = TextEngine::toFloat(metrics.height()), + .spaceAdvanceX = TextEngine::toFloat(extendedMetrics.spaceAdvanceX), + .lineThickness = TextEngine::toFloat(extendedMetrics.lineThickness), + .xHeight = TextEngine::toFloat(extendedMetrics.xHeight), + .capitalHeight = TextEngine::toFloat(extendedMetrics.capitalHeight), + }; } -GlyphRun& PreparedText::runVisual(uint32_t index) { - return runs[visualOrder[index]]; +void FontManager::setGlyphCacheMemoryBudget(size_t bytes) { + lock_quard_cond lk(m_lock); + m_textEngine->glyphCache->setMemoryBudget(bytes); } -const GlyphRun& PreparedText::runVisual(uint32_t index) const { - return runs[visualOrder[index]]; +void FontManager::clearGlyphCache() { + lock_quard_cond lk(m_lock); + m_textEngine->glyphCache->clear(); } -Range PreparedText::graphemeToCharacters(uint32_t graphemeIndex) const { - return { graphemeToCharacter(graphemeIndex), graphemeToCharacter(graphemeIndex + 1) }; +void FontManager::lock() const noexcept { + if (m_lock) { + m_lock->lock(); + } } -uint32_t PreparedText::graphemeToCharacter(uint32_t graphemeIndex) const { - return graphemeBoundaries[std::min(graphemeIndex, (uint32_t)graphemeBoundaries.size() - 1u)]; +bool FontManager::try_lock() const noexcept { + if (m_lock) { + return m_lock->try_lock(); + } + return true; } -uint32_t PreparedText::characterToGrapheme(uint32_t charIndex) const { - if (charIndex == 0) - return 0; - return std::upper_bound(graphemeBoundaries.begin(), graphemeBoundaries.end(), charIndex) - - graphemeBoundaries.begin() - 1; +void FontManager::unlock() const noexcept { + if (m_lock) { + m_lock->unlock(); + } } float FontMetrics::linegap() const noexcept { @@ -2145,10 +1586,35 @@ std::optional> RichText::fromHtml(std::strin return std::nullopt; } } + } // namespace Internal +Font Font::operator()(FontWeight weight) const { + Font result = *this; + result.weight = weight; + return result; +} + +Font Font::operator()(FontStyle style) const { + Font result = *this; + result.style = style; + return result; +} + +Font Font::operator()(float fontSize) const { + Font result = *this; + result.fontSize = fontSize; + return result; +} + +Font Font::operator()(std::string fontFamily) const { + Font result = *this; + result.fontFamily = std::move(fontFamily); + return result; +} + std::recursive_mutex fontMutex; -std::optional fonts(std::in_place, &fontMutex, 3, 5000); +std::optional fonts(std::in_place, &fontMutex, 3); } // namespace Brisk diff --git a/src/graphics/Fonts_test.cpp b/src/graphics/Fonts_test.cpp index 949aa3f9..4409cc39 100644 --- a/src/graphics/Fonts_test.cpp +++ b/src/graphics/Fonts_test.cpp @@ -1,912 +1,29 @@ /* * Brisk * - * Cross-platform application framework - * -------------------------------------------------------------- - * * Copyright (C) 2025 Brisk Developers * * This file is part of the Brisk library. - * - * Brisk is dual-licensed under the GNU General Public License, version 2 (GPL-2.0+), - * and a commercial license. You may use, modify, and distribute this software under - * the terms of the GPL-2.0+ license if you comply with its conditions. - * - * You should have received a copy of the GNU General Public License along with this program. - * If not, see . - * - * If you do not wish to be bound by the GPL-2.0+ license, you must purchase a commercial - * license. For commercial licensing options, please visit: https://brisklib.com */ -#include #include -#include +#include #include -#include "Catch2Utils.hpp" -#include "../core/test/HelloWorld.hpp" -#include -#include "VisualTests.hpp" namespace Brisk { -std::string glyphRunToString(const GlyphRun& run); -} // namespace Brisk - -template <> -struct fmt::formatter : fmt::formatter { - template - auto format(const Brisk::FontMetrics& value, FormatContext& ctx) const { - return fmt::formatter::format( - fmt::format("{{ {:.3f}, {:.3f}, {:.3f}, {:.3f}, {:.3f}, {:.3f}, {:.3f}, {:.3f} }}", value.size, - value.ascender, value.descender, value.height, value.spaceAdvanceX, - value.lineThickness, value.xHeight, value.capitalHeight), - ctx); - } -}; - -template <> -struct fmt::formatter : fmt::formatter { - template - auto format(const Brisk::AscenderDescender& value, FormatContext& ctx) const { - return fmt::formatter::format(fmt::format("{}/{}", value.ascender, value.descender), - ctx); - } -}; - -template <> -struct fmt::formatter : fmt::formatter { - template - auto format(const Brisk::FontManager::FontKey& value, FormatContext& ctx) const { - return fmt::formatter::format( - fmt::format("{} {} {}", std::get<0>(value), std::get<1>(value), std::get<2>(value)), ctx); - } -}; - -template <> -struct fmt::formatter : fmt::formatter { - template - auto format(const Brisk::GlyphRun& value, FormatContext& ctx) const { - - return fmt::formatter::format(Brisk::glyphRunToString(value), ctx); - } -}; - -template <> -struct fmt::formatter : fmt::formatter { - template - auto format(const Brisk::PreparedText& value, FormatContext& ctx) const { - return fmt::formatter::format( - fmt::format("Prepared\n{}\n///////////////////////", fmt::join(value.runs, "\n----\n")), ctx); - } -}; - -namespace Brisk { -static Rc fontManager; - -static std::string codepointDetails(char32_t value) { - if (uint32_t(value) >= 32) - return fmt::format("{} ('{}')", unicodeChar(value), - Brisk::utf32ToUtf8(std::u32string_view(&value, 1))); - else - return fmt::format("{} ", unicodeChar(value)); -} - -std::string glyphRunToString(const GlyphRun& run) { - std::string result = - fmt::format("Positioned at {{ {:6.2f}, {:6.2f} }}:\n", run.position.x, run.position.y); - for (int i = 0; i < run.glyphs.size(); ++i) { - const Internal::Glyph& g = run.glyphs[i]; - Internal::GlyphData d = g.load(run).value_or(Internal::GlyphData{}); - Brisk::FontManager::FontKey key = Brisk::fontManager->faceToKey(run.face); - - std::string flags; - if (g.flags && Internal::GlyphFlags::AtLineBreak) - flags += "ALB "; - else - flags += " "; - if (g.flags && Internal::GlyphFlags::SafeToBreak) - flags += "STB "; - else - flags += " "; - - result += fmt::format( - "{{ {:5}, {}, cl={:2}..{:<2}, L={:6.2f}, R={:6.2f}, w={:6.2f}, pos={{ {:6.2f}, {:6.2f} }}, " - "adv={:6.2f}, sz={:2d}x{:<2d}, sp={:08X}, {}, {} }}\n", - g.glyph, codepointDetails(g.codepoint), g.begin_char, g.end_char, g.left_caret, g.right_caret, - g.right_caret - g.left_caret, g.pos.x, g.pos.y, d.advance_x, d.size.x, d.size.y, UINT32_MAX, key, - flags); - } - return result; -} - -} // namespace Brisk - -namespace Brisk { - -TEST_CASE("textBreakPositions") { - CHECK(utf32ToUtf16(U"abc").size() == 3); - CHECK(utf32ToUtf16(U"αβγ").size() == 3); - CHECK(utf32ToUtf16(U"一二三").size() == 3); - CHECK(utf32ToUtf16(U"𠀀𠀁𠀂").size() == 6); - CHECK(textBreakPositions(U"abc", TextBreakMode::Grapheme) == std::vector{ 0, 1, 2, 3 }); - CHECK(textBreakPositions(U"αβγ", TextBreakMode::Grapheme) == std::vector{ 0, 1, 2, 3 }); - CHECK(textBreakPositions(U"一二三", TextBreakMode::Grapheme) == std::vector{ 0, 1, 2, 3 }); - CHECK(textBreakPositions(U"𠀀𠀁𠀂", TextBreakMode::Grapheme) == std::vector{ 0, 1, 2, 3 }); - - CHECK(textBreakPositions(U"abc abc", TextBreakMode::Grapheme) == - std::vector{ 0, 1, 2, 3, 4, 5, 6, 7 }); - CHECK(textBreakPositions(U"𠀀𠀁𠀂 𠀀𠀁𠀂", TextBreakMode::Grapheme) == - std::vector{ 0, 1, 2, 3, 4, 5, 6, 7 }); - - CHECK(textBreakPositions(U"á", TextBreakMode::Grapheme) == std::vector{ 0, 2 }); - CHECK(textBreakPositions(U"á̈", TextBreakMode::Grapheme) == std::vector{ 0, 3 }); - - CHECK(textBreakPositions(U"a\nb\nc", TextBreakMode::Grapheme) == - std::vector{ 0, 1, 2, 3, 4, 5 }); - CHECK(textBreakPositions(U"a\n\nb", TextBreakMode::Grapheme) == std::vector{ 0, 1, 2, 3, 4 }); - - CHECK(textBreakPositions(U"abc", TextBreakMode::Word) == std::vector{ 0, 3 }); - CHECK(textBreakPositions(U"abc def", TextBreakMode::Word) == std::vector{ 0, 3, 4, 7 }); - - CHECK(textBreakPositions(U"abc", TextBreakMode::Line) == std::vector{ 0, 3 }); - CHECK(textBreakPositions(U"abc def", TextBreakMode::Line) == std::vector{ 0, 4, 7 }); - - CHECK(textBreakPositions(U"A B C D E F", TextBreakMode::Line) == - std::vector{ 0, 2, 4, 6, 8, 10, 11 }); - - if (icuAvailable) { - CHECK(textBreakPositions(U"a\u00ADb", TextBreakMode::Line) == std::vector{ 0, 2, 3 }); - } -} - -TEST_CASE("splitTextRuns") { - CHECK(Internal::toVisualOrder(Internal::splitTextRuns(U"𠀀𠀁𠀂 𠀀𠀁𠀂", TextDirection::LTR)) == - std::vector{ - Internal::TextRun{ - .direction = TextDirection::LTR, .begin = 0, .end = 7, .visualOrder = 0, .face = nullptr }, - }); - if (icuAvailable) { - fmt::println("ICU is available"); - CHECK(Internal::toVisualOrder( - Internal::splitTextRuns(U"𠀀𠀁𠀂 \U0000200F123\U0000200E 𠀀𠀁𠀂", TextDirection::LTR)) == - std::vector{ - Internal::TextRun{ .direction = TextDirection::LTR, - .begin = 0, - .end = 4, - .visualOrder = 0, - .face = nullptr }, - Internal::TextRun{ .direction = TextDirection::LTR, - .begin = 5, - .end = 8, - .visualOrder = 7, - .face = nullptr }, - Internal::TextRun{ .direction = TextDirection::RTL, - .begin = 4, - .end = 5, - .visualOrder = 10, - .face = nullptr }, - Internal::TextRun{ .direction = TextDirection::LTR, - .begin = 8, - .end = 13, - .visualOrder = 11, - .face = nullptr }, - }); - } else { - fmt::println("ICU is not available, skipping some tests"); - } -} - -TEST_CASE("FontManager") { - - fontManager = rcnew FontManager(nullptr, 1, 5000); - - auto ttf = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf"); - REQUIRE(ttf.has_value()); - auto ttf2 = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "GoNotoCurrent-Regular.ttf"); - REQUIRE(ttf2.has_value()); - auto ttf3 = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "SourceCodePro-Medium.ttf"); - REQUIRE(ttf3.has_value()); - auto ttf4 = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Light.ttf"); - REQUIRE(ttf4.has_value()); - - fontManager->addFont("lato", FontStyle::Normal, FontWeight::Regular, *ttf, true, FontFlags::Default); - CHECK(fontManager->fontFamilyStyles("lato") == std::vector{ FontStyleAndWeight{ - FontStyle::Normal, - FontWeight::Regular, - } }); - - fontManager->addFont("noto", FontStyle::Normal, FontWeight::Regular, *ttf2, true, FontFlags::Default); - - fontManager->addFont("mono", FontStyle::Normal, FontWeight::Regular, *ttf3, true, FontFlags::Default); - fontManager->addFont("latolight", FontStyle::Normal, FontWeight::Regular, *ttf4, true, - FontFlags::Default); - - Font font; - font.fontSize = 10; - font.fontFamily = "lato,noto"; - font.lineHeight = 1.f; - FontMetrics metrics = fontManager->metrics(font); - CHECK(metrics == FontMetrics{ 10, 10, -3, 12, 2.531250, 0.750, 5.080, 7.180 }); - - PreparedText run; - - run = fontManager->prepare(font, U""s); - REQUIRE(run.runs.size() == 0); - REQUIRE(run.graphemeBoundaries.size() == 1); - REQUIRE(run.lines.size() == 1); - CHECK(run.lines[0].runRange == Range{ 0u, 0u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 1u }); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 1); - REQUIRE(run.ranges.size() == 0); - CHECK(run.caretPositions[0] == 0); - - run = fontManager->prepare(font, U"abc"s); - REQUIRE(run.runs.size() == 1); - REQUIRE(run.graphemeBoundaries.size() == 4); - CHECK(run.runs[0].charRange() == Range{ 0u, 3u }); - CHECK(run.runs[0].glyphs.size() == 3); - CHECK(run.runs[0].direction == TextDirection::LTR); - CHECK(run.runs[0].glyphs[0].codepoint == U'a'); - CHECK(run.runs[0].glyphs[1].codepoint == U'b'); - CHECK(run.runs[0].glyphs[2].codepoint == U'c'); - CHECK(run.runs[0].glyphs[1].pos.x > run.runs[0].glyphs[0].pos.x); - CHECK(run.runs[0].glyphs[2].pos.x > run.runs[0].glyphs[1].pos.x); - CHECK(run.runs[0].glyphs[0].charRange() == Range{ 0u, 1u }); // ltr - CHECK(run.runs[0].glyphs[1].charRange() == Range{ 1u, 2u }); // ltr - CHECK(run.runs[0].glyphs[2].charRange() == Range{ 2u, 3u }); // ltr - CHECK(run.runs[0].glyphs[1].left_caret > run.runs[0].glyphs[0].left_caret); - CHECK(run.runs[0].glyphs[2].left_caret > run.runs[0].glyphs[1].left_caret); - CHECK(run.runs[0].glyphs[1].right_caret > run.runs[0].glyphs[1].left_caret); - REQUIRE(run.lines.size() == 1); - CHECK(run.lines[0].runRange == Range{ 0u, 1u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 4u }); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 4); - REQUIRE(run.ranges.size() == 3); - CHECK(run.caretPositions[1] > run.caretPositions[0]); - CHECK(run.caretPositions[2] > run.caretPositions[1]); - CHECK(run.caretPositions[3] > run.caretPositions[2]); - CHECK(run.graphemeToCaret(0) == PointF{ 0, 0 }); - CHECK(run.graphemeToCaret(1) == PointF{ run.runs[0].glyphs[0].right_caret, 0 }); - CHECK(run.graphemeToCaret(2) == PointF{ run.runs[0].glyphs[1].right_caret, 0 }); - CHECK(run.graphemeToCaret(3) == PointF{ run.runs[0].glyphs[2].right_caret, 0 }); - - run = fontManager->prepare(font, U"ǎb"s); - REQUIRE(run.runs.size() == 1); - CHECK(run.runs[0].charRange() == Range{ 0u, 3u }); - CHECK(run.runs[0].glyphs.size() == 2); - CHECK(run.runs[0].direction == TextDirection::LTR); - CHECK(run.runs[0].glyphs[0].codepoint == U'a'); // ǎ represented by combined glyph - CHECK(run.runs[0].glyphs[1].codepoint == U'b'); - CHECK(run.runs[0].glyphs[1].pos.x > run.runs[0].glyphs[0].pos.x); - CHECK(run.runs[0].glyphs[0].charRange() == Range{ 0u, 2u }); // ltr - CHECK(run.runs[0].glyphs[1].charRange() == Range{ 2u, 3u }); // ltr - CHECK(run.runs[0].glyphs[1].left_caret > run.runs[0].glyphs[0].left_caret); - CHECK(run.runs[0].glyphs[1].right_caret > run.runs[0].glyphs[1].left_caret); - REQUIRE(run.lines.size() == 1); - CHECK(run.lines[0].runRange == Range{ 0u, 1u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 3u }); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 3); - REQUIRE(run.ranges.size() == 2); - CHECK(run.caretPositions[1] > run.caretPositions[0]); - CHECK(run.caretPositions[2] > run.caretPositions[1]); - CHECK(run.graphemeToCaret(0) == PointF{ 0, 0 }); - CHECK(run.graphemeToCaret(1) == PointF{ run.runs[0].glyphs[0].right_caret, 0 }); - CHECK(run.graphemeToCaret(2) == PointF{ run.runs[0].glyphs[1].right_caret, 0 }); - - run = fontManager->prepare(font, U"a\nb"s); - REQUIRE(run.runs.size() == 2); - CHECK(run.runs[0].charRange() == Range{ 0u, 1u }); - CHECK(run.runs[0].glyphs.size() == 1); - CHECK(run.runs[0].direction == TextDirection::LTR); - CHECK(run.runs[0].glyphs[0].codepoint == U'a'); - CHECK(run.runs[0].glyphs[0].charRange() == Range{ 0u, 1u }); - CHECK(run.runs[1].charRange() == Range{ 2u, 3u }); - CHECK(run.runs[1].glyphs.size() == 1); - CHECK(run.runs[1].direction == TextDirection::LTR); - CHECK(run.runs[1].glyphs[0].codepoint == U'b'); - CHECK(run.runs[1].glyphs[0].charRange() == Range{ 2u, 3u }); - REQUIRE(run.lines.size() == 2); - CHECK(run.lines[0].runRange == Range{ 0u, 1u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 2u }); - CHECK(run.lines[1].runRange == Range{ 1u, 2u }); - CHECK(run.lines[1].graphemeRange == Range{ 2u, 4u }); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 4); - REQUIRE(run.ranges.size() == 3); - CHECK(run.caretPositions[0] == 0); - CHECK(run.caretPositions[1] > 0); - CHECK(run.caretPositions[2] == 0); - CHECK(run.caretPositions[3] > 0); - CHECK(run.graphemeToCaret(0) == PointF{ 0, 0 }); - CHECK(run.graphemeToCaret(1) == PointF{ run.runs[0].glyphs[0].right_caret, 0 }); - CHECK(run.graphemeToCaret(2) == PointF{ 0, 12 }); - CHECK(run.graphemeToCaret(3) == PointF{ run.runs[1].glyphs[0].right_caret, 12 }); - - run = fontManager->prepare(font, U"a\n"s); - REQUIRE(run.runs.size() == 1); - CHECK(run.runs[0].charRange() == Range{ 0u, 1u }); - CHECK(run.runs[0].glyphs.size() == 1); - CHECK(run.runs[0].direction == TextDirection::LTR); - CHECK(run.runs[0].glyphs[0].codepoint == U'a'); - CHECK(run.runs[0].glyphs[0].charRange() == Range{ 0u, 1u }); - REQUIRE(run.lines.size() == 2); - CHECK(run.lines[0].runRange == Range{ 0u, 1u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 2u }); - CHECK(run.lines[1].runRange == Range{ 1u, 1u }); - CHECK(run.lines[1].graphemeRange == Range{ 2u, 3u }); - - run = fontManager->prepare(font, U"a\n\nb"s); - REQUIRE(run.runs.size() == 2); - CHECK(run.runs[0].charRange() == Range{ 0u, 1u }); - CHECK(run.runs[0].glyphs.size() == 1); - CHECK(run.runs[0].direction == TextDirection::LTR); - CHECK(run.runs[0].glyphs[0].codepoint == U'a'); - CHECK(run.runs[0].glyphs[0].charRange() == Range{ 0u, 1u }); - CHECK(run.runs[1].charRange() == Range{ 3u, 4u }); - CHECK(run.runs[1].glyphs.size() == 1); - CHECK(run.runs[1].direction == TextDirection::LTR); - CHECK(run.runs[1].glyphs[0].codepoint == U'b'); - CHECK(run.runs[1].glyphs[0].charRange() == Range{ 3u, 4u }); - REQUIRE(run.lines.size() == 3); - CHECK(run.lines[0].runRange == Range{ 0u, 1u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 2u }); - CHECK(run.lines[1].runRange == Range{ 1u, 1u }); - CHECK(run.lines[1].graphemeRange == Range{ 2u, 3u }); - CHECK(run.lines[2].runRange == Range{ 1u, 2u }); - CHECK(run.lines[2].graphemeRange == Range{ 3u, 5u }); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 5); - REQUIRE(run.ranges.size() == 4); - CHECK(run.caretPositions[0] == 0); - CHECK(run.caretPositions[1] > 0); - CHECK(run.caretPositions[2] == 0); - CHECK(run.caretPositions[3] == 0); - CHECK(run.caretPositions[4] > 0); - // CHECK(run.graphemeToCaret(0) == PointF{ 0, 0 }); - // CHECK(run.graphemeToCaret(1) == PointF{ run.runs[0].glyphs[0].right_caret, 0 }); - // CHECK(run.graphemeToCaret(2) == PointF{ 0, 12 }); - // CHECK(run.graphemeToCaret(3) == PointF{ run.runs[1].glyphs[0].right_caret, 12 }); - - run = fontManager->prepare(font, TextWithOptions{ U"ab"s, TextOptions::WrapAnywhere }, 1.f); - REQUIRE(run.runs.size() == 2); - CHECK(run.runs[0].charRange() == Range{ 0u, 1u }); - CHECK(run.runs[0].glyphs.size() == 1); - CHECK(run.runs[0].direction == TextDirection::LTR); - CHECK(run.runs[0].glyphs[0].codepoint == U'a'); - CHECK(run.runs[0].glyphs[0].charRange() == Range{ 0u, 1u }); - CHECK(run.runs[1].charRange() == Range{ 1u, 2u }); - CHECK(run.runs[1].glyphs.size() == 1); - CHECK(run.runs[1].direction == TextDirection::LTR); - CHECK(run.runs[1].glyphs[0].codepoint == U'b'); - CHECK(run.runs[1].glyphs[0].charRange() == Range{ 1u, 2u }); - REQUIRE(run.lines.size() == 2); - CHECK(run.lines[0].runRange == Range{ 0u, 1u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 1u }); - CHECK(run.lines[1].runRange == Range{ 1u, 2u }); - CHECK(run.lines[1].graphemeRange == Range{ 1u, 3u }); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 3); - REQUIRE(run.ranges.size() == 2); - CHECK(run.caretPositions[0] == 0); - CHECK(run.caretPositions[1] == 0); - CHECK(run.caretPositions[2] > 0); - CHECK(run.graphemeToCaret(0) == PointF{ 0, 0 }); - CHECK(run.graphemeToCaret(1) == PointF{ 0, 12 }); - CHECK(run.graphemeToCaret(2) == PointF{ run.runs[1].glyphs[0].right_caret, 12 }); - - if (icuAvailable) { - run = fontManager->prepare(font, U"ابت"s); - REQUIRE(run.runs.size() == 1); - CHECK(run.runs[0].charRange() == Range{ 0u, 3u }); - CHECK(run.runs[0].glyphs.size() == 3); - CHECK(run.runs[0].direction == TextDirection::RTL); - CHECK(run.runs[0].glyphs[0].codepoint == U'ت'); - CHECK(run.runs[0].glyphs[1].codepoint == U'ب'); - CHECK(run.runs[0].glyphs[2].codepoint == U'ا'); - CHECK(run.runs[0].glyphs[1].pos.x > run.runs[0].glyphs[0].pos.x); - CHECK(run.runs[0].glyphs[2].pos.x > run.runs[0].glyphs[1].pos.x); - CHECK(run.runs[0].glyphs[0].charRange() == Range{ 2u, 3u }); // rtl - CHECK(run.runs[0].glyphs[1].charRange() == Range{ 1u, 2u }); // rtl - CHECK(run.runs[0].glyphs[2].charRange() == Range{ 0u, 1u }); // rtl - CHECK(run.runs[0].glyphs[1].left_caret > run.runs[0].glyphs[0].left_caret); - CHECK(run.runs[0].glyphs[2].left_caret > run.runs[0].glyphs[1].left_caret); - CHECK(run.runs[0].glyphs[1].right_caret > run.runs[0].glyphs[1].left_caret); - REQUIRE(run.lines.size() == 1); - CHECK(run.lines[0].runRange == Range{ 0u, 1u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 4u }); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 4); - REQUIRE(run.ranges.size() == 3); - CHECK(run.caretPositions[1] < run.caretPositions[0]); // rtl - CHECK(run.caretPositions[2] < run.caretPositions[1]); // rtl - CHECK(run.caretPositions[3] < run.caretPositions[2]); // rtl - CHECK(run.graphemeToCaret(0) == PointF{ run.runs[0].glyphs[2].right_caret, 0 }); - CHECK(run.graphemeToCaret(1) == PointF{ run.runs[0].glyphs[1].right_caret, 0 }); - CHECK(run.graphemeToCaret(2) == PointF{ run.runs[0].glyphs[0].right_caret, 0 }); - CHECK(run.graphemeToCaret(3) == PointF{ run.runs[0].glyphs[0].left_caret, 0 }); - - run = fontManager->prepare(font, U"abאבcd"s); - REQUIRE(run.runs.size() == 3); - CHECK(run.runs[0].charRange() == Range{ 0u, 2u }); - CHECK(run.runs[0].glyphs.size() == 2); - CHECK(run.runs[0].direction == TextDirection::LTR); - CHECK(run.runs[1].charRange() == Range{ 2u, 4u }); - CHECK(run.runs[1].glyphs.size() == 2); - CHECK(run.runs[1].direction == TextDirection::RTL); - CHECK(run.runs[2].charRange() == Range{ 4u, 6u }); - CHECK(run.runs[2].glyphs.size() == 2); - CHECK(run.runs[2].direction == TextDirection::LTR); - REQUIRE(run.lines.size() == 1); - CHECK(run.lines[0].runRange == Range{ 0u, 3u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 7u }); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 7); - REQUIRE(run.ranges.size() == 6); - CHECK(run.caretPositions[0] == 0); - CHECK(run.caretPositions[1] > run.caretPositions[0]); - CHECK(run.caretPositions[2] > run.caretPositions[1]); - CHECK(run.caretPositions[3] > run.caretPositions[2]); - CHECK(run.caretPositions[4] < run.caretPositions[3]); - CHECK(run.caretPositions[4] == run.caretPositions[2]); - CHECK(run.caretPositions[5] > run.caretPositions[3]); - CHECK(run.caretPositions[6] > run.caretPositions[5]); - - run = fontManager->prepare(font, TextWithOptions{ U"abאבcd"s, TextOptions::WrapAnywhere }, 22); - REQUIRE(run.runs.size() == 4); - CHECK(run.runs[0].charRange() == Range{ 0u, 2u }); - CHECK(run.runs[0].glyphs.size() == 2); - CHECK(run.runs[0].direction == TextDirection::LTR); - CHECK(run.runs[1].charRange() == Range{ 2u, 3u }); - CHECK(run.runs[1].glyphs.size() == 1); - CHECK(run.runs[1].direction == TextDirection::RTL); - CHECK(run.runs[2].charRange() == Range{ 3u, 4u }); - CHECK(run.runs[2].glyphs.size() == 1); - CHECK(run.runs[2].direction == TextDirection::RTL); - CHECK(run.runs[3].charRange() == Range{ 4u, 6u }); - CHECK(run.runs[3].glyphs.size() == 2); - CHECK(run.runs[3].direction == TextDirection::LTR); - REQUIRE(run.lines.size() == 2); - CHECK(run.lines[0].runRange == Range{ 0u, 2u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 3u }); - CHECK(run.lines[1].runRange == Range{ 2u, 4u }); - CHECK(run.lines[1].graphemeRange == Range{ 3u, 7u }); - } - - run = fontManager->prepare(font, U"fi"s); - REQUIRE(run.runs.size() == 1); - CHECK(run.runs[0].charRange() == Range{ 0u, 2u }); - CHECK(run.runs[0].glyphs.size() == 1); - run.updateCaretData(); - REQUIRE(run.caretPositions.size() == 3); - CHECK(run.caretPositions[0] == 0); - CHECK(run.caretPositions[1] > run.caretPositions[0]); - CHECK(run.caretPositions[2] > run.caretPositions[1]); - CHECK(!std::isnan(run.caretPositions[2])); - REQUIRE(run.lines.size() == 1); - CHECK(run.lines[0].runRange == Range{ 0u, 1u }); - CHECK(run.lines[0].graphemeRange == Range{ 0u, 3u }); - - run = fontManager->prepare(font, U"a"s); - REQUIRE(run.lines.size() == 1); - REQUIRE(run.lines[0].baseline == 0); - run.updateCaretData(); - CHECK(run.graphemeToCaret(0).y == 0); - CHECK(run.graphemeToCaret(1).y == 0); - CHECK(run.bounds().height() == 12); - - run = fontManager->prepare(font, U"a\n"s); - REQUIRE(run.lines.size() == 2); - REQUIRE(run.lines[0].baseline == 0); - REQUIRE(run.lines[1].baseline == 12); - CHECK(run.lines[1].ascDesc == run.lines[0].ascDesc); - run.updateCaretData(); - CHECK(run.graphemeToCaret(0).y == 0); - CHECK(run.graphemeToCaret(1).y == 0); - CHECK(run.graphemeToCaret(2).y == 12); - CHECK(run.bounds().height() == 24); - - run = fontManager->prepare(font, U"a\nb"s); - REQUIRE(run.lines.size() == 2); - REQUIRE(run.lines[0].baseline == 0); - REQUIRE(run.lines[1].baseline == 12); - run.updateCaretData(); - CHECK(run.graphemeToCaret(0).y == 0); - CHECK(run.graphemeToCaret(1).y == 0); - CHECK(run.graphemeToCaret(2).y == 12); - CHECK(run.bounds().height() == 24); - - run = fontManager->prepare(font, U"a\n\n"s); - REQUIRE(run.lines.size() == 3); - REQUIRE(run.lines[0].baseline == 0); - REQUIRE(run.lines[1].baseline == 12); - REQUIRE(run.lines[2].baseline == 24); - run.updateCaretData(); - CHECK(run.graphemeToCaret(0).y == 0); - CHECK(run.graphemeToCaret(1).y == 0); - CHECK(run.graphemeToCaret(2).y == 12); - CHECK(run.graphemeToCaret(3).y == 24); - CHECK(run.bounds().height() == 36); - - RectangleF bounds = fontManager->bounds(font, U"Hello, world!"s, GlyphRunBounds::Text); - bounds = fontManager->bounds(font, U" Hello, world!"s, GlyphRunBounds::Text); - bounds = fontManager->bounds(font, U" Hello, world! "s, GlyphRunBounds::Text); - bounds = fontManager->bounds(font, U"Hello, world! "s, GlyphRunBounds::Text); - - [[maybe_unused]] Size size = { 512, 64 }; - - Font bigFont{ "lato,noto", 36.f }; - - static std::set requiresIcu{ - 3, 25, 47, 48, 71, - }; - for (int i = 0; i < std::size(helloWorld); i++) { - if (!icuAvailable && requiresIcu.contains(i)) { - continue; - } - visualTestMono(fmt::format("hello{}", i), { 512, 64 }, [&](Rc image) { - Font font{ "lato,noto", 36.f }; - auto run = fontManager->prepare(font, helloWorld[i]); - fontManager->testRender(image, run, { 5, 42 }); - }); - } - - visualTestMono("nl-multi", { 256, 128 }, [&](Rc image) { - auto run = - fontManager->prepare(bigFont, TextWithOptions{ utf8ToUtf32("ABC\nDEF"), TextOptions::Default }); - fontManager->testRender(image, run, { 5, 42 }); - }); - visualTestMono("nl-single", { 256, 128 }, [&](Rc image) { - auto run = fontManager->prepare(bigFont, - TextWithOptions{ utf8ToUtf32("ABC\nDEF"), TextOptions::SingleLine }); - fontManager->testRender(image, run, { 5, 42 }); - }); - - constexpr auto diac = - UR"(a à â ă å a̋ ä a̧ ǎ ã á ä́ á̈ -i ì î ĭ i̊ i̋ ï i̧ ǐ ĩ í ḯ í̈ -q q̀ q̂ q̆ q̊ q̋ q̈ q̧ q̌ q̃ q́ q̈́ q́̈ -I Ì Î Ĭ I̊ I̋ Ï I̧ Ǐ Ĩ Í Ḯ Í̈ -Њ Њ̀ Њ̂ Њ̆ Њ̊ Њ̋ Њ̈ Њ̧ Њ̌ Њ̃ Њ́ Њ̈́ Њ́̈ -)"; - - visualTestMono("diacritics-lato", { 650, 290 }, [&](Rc image) { - Font font{ "lato", 36.f }; - font.tabWidth = 5.f; - auto run = fontManager->prepare(font, diac); - fontManager->testRender(image, run, { 5, 42 }); - }); - visualTestMono("diacritics-noto", { 650, 290 }, [&](Rc image) { - Font font{ "noto", 36.f }; - font.tabWidth = 5.f; - auto run = fontManager->prepare(font, diac); - fontManager->testRender(image, run, { 5, 42 }); - }); - visualTestMono("bounds-text", { 128, 64 }, [&](Rc image) { - Font font{ "lato", 36.f }; - auto run = fontManager->prepare(font, U"a"); - fontManager->testRender(image, run, { 5, 42 }, TestRenderFlags::TextBounds); - }); - visualTestMono("bounds-text-bar", { 128, 64 }, [&](Rc image) { - Font font{ "lato", 36.f }; - auto run = fontManager->prepare(font, U"|"); - fontManager->testRender(image, run, { 5, 42 }, TestRenderFlags::TextBounds); - }); - visualTestMono("bounds-text2", { 128, 64 }, [&](Rc image) { - Font font{ "lato", 36.f }; - auto run = fontManager->prepare(font, U"a "); - fontManager->testRender(image, run, { 5, 42 }, TestRenderFlags::TextBounds); - }); - visualTestMono("bounds-text3", { 128, 64 }, [&](Rc image) { - Font font{ "lato", 36.f }; - auto run = fontManager->prepare(font, U" a"); - fontManager->testRender(image, run, { 5, 42 }, TestRenderFlags::TextBounds); - }); - visualTestMono("bounds-text4", { 128, 64 }, [&](Rc image) { - Font font{ "lato", 36.f }; - auto run = fontManager->prepare(font, U" a"); - fontManager->testRender(image, run, { 5, 42 }, TestRenderFlags::TextBounds); - }); - visualTestMono("bounds-text5", { 128, 64 }, [&](Rc image) { - Font font{ "lato", 36.f }; - auto run = fontManager->prepare(font, U" aa"); - fontManager->testRender(image, run, { 5, 42 }, TestRenderFlags::TextBounds); - }); - visualTestMono("bounds-text6", { 128, 64 }, [&](Rc image) { - Font font{ "lato", 36.f }; - auto run = fontManager->prepare(font, U" a|"); - fontManager->testRender(image, run, { 5, 42 }, TestRenderFlags::TextBounds); - }); - - visualTestMono("empty-lines", { 128, 256 }, [&](Rc image) { - Font font{ "lato", 24.f }; - auto run = fontManager->prepare(font, U"a\nb\n\nd\ne"); - fontManager->testRender(image, run, { 5, 32 }, TestRenderFlags::TextBounds); - }); - - visualTestMono("lineHeight1", { 64, 64 }, [&](Rc image) { - Font font{ "lato", 16.f }; - font.lineHeight = 1.f; - auto run = fontManager->prepare(font, U"1st line\n2nd line"); - fontManager->testRender(image, run, { 3, 20 }, TestRenderFlags::TextBounds); - }); - visualTestMono("lineHeight1dot5", { 64, 64 }, [&](Rc image) { - Font font{ "lato", 16.f }; - font.lineHeight = 1.5f; - auto run = fontManager->prepare(font, U"1st line\n2nd line"); - fontManager->testRender(image, run, { 3, 20 }, TestRenderFlags::TextBounds); - }); - - visualTestMono("unicode-suppl", { 256, 64 }, [&](Rc image) { - Font font{ "noto", 36.f }; - auto run = fontManager->prepare( - font, U"\U00010140\U00010141\U00010142\U00010143\U00010144\U00010145\U00010146\U00010147"); - fontManager->testRender(image, run, { 5, 42 }); - }); - - visualTestMono("wrapSpaces", { 64, 64 }, [&](Rc image) { - Font font{ "lato", 16.f }; - font.lineHeight = 0.8f; - auto run = fontManager->prepare(font, U"1 2 3 4 5 6 ", 41); - fontManager->testRender(image, run, { 3, 13 }, TestRenderFlags::TextBounds, { 3 + 41 }); - }); - - if (icuAvailable) { - visualTestMono("mixed", { 512, 64 }, [&](Rc image) { - Font font{ "noto", 36.f }; - auto run = fontManager->prepare(font, U"abcdef مرحبا بالعالم!"); - fontManager->testRender(image, run, { 5, 42 }); - }); - - visualTestMono("mixed2", { 512, 64 }, [&](Rc image) { - Font font{ "noto", 36.f }; - auto run = fontManager->prepare(font, U"123456 مرحبا بالعالم!"); - fontManager->testRender(image, run, { 5, 42 }); - }); - - visualTestMono("mixed3", { 512, 64 }, [&](Rc image) { - Font font{ "noto", 36.f }; - auto run = fontManager->prepare(font, U"مرحبا (بالعالم)!"); - fontManager->testRender(image, run, { 5, 42 }); - }); - } - - visualTestMono("wrapped-abc", { 128, 128 }, [&](Rc image) { - Font font{ "noto", 18.f }; - auto t = U"A B C D E F G H I J K L M N O P Q R S T U V W X Y Z"; - auto run = fontManager->prepare(font, t, 120); - fontManager->testRender(image, run, { 3, 20 }, TestRenderFlags::None, { 3, 3 + 120 }, { 20 }); - }); - visualTestMono("wrapped-abc-big", { 4 * 128, 4 * 128 }, [&](Rc image) { - Font font{ "noto", 4 * 18.f }; - auto t = U"A B C D E F G H I J K L M N O P Q R S T U V W X Y Z"; - auto run = fontManager->prepare(font, t, 4 * 120); - fontManager->testRender(image, run, { 4 * 3, 4 * 20 }, TestRenderFlags::None, - { 4 * 3, 4 * 3 + 4 * 120 }, { 4 * 20 }); - }); - visualTestMono("wrapped-abc2", { 128, 128 }, [&](Rc image) { - Font font{ "noto", 18.f }; - auto t = U"A B C D E F G H I J K L M N O P Q R S T U V W X Y Z"; - auto run = fontManager->prepare(font, t, 110); - fontManager->testRender(image, run, { 3, 20 }, TestRenderFlags::None, { 3, 3 + 110 }, { 20 }); - }); - visualTestMono("wrapped-abc3", { 128, 128 }, [&](Rc image) { - Font font{ "noto", 16.f }; - auto t = U"ABCDEFGHIJKLM N O P Q R S T U V W X Y Z"; - auto run = fontManager->prepare(font, t, 100); - fontManager->testRender(image, run, { 3, 20 }, TestRenderFlags::None, { 3, 3 + 100 }, { 20 }); - }); - visualTestMono("wrapped-abc4", { 128, 128 }, [&](Rc image) { - Font font{ "noto", 16.f }; - auto t = U"A B C D E F G H"; - auto run = fontManager->prepare(font, t, 24); - fontManager->testRender(image, run, { 3, 20 }, TestRenderFlags::None, { 3, 3 + 24 }, { 20 }); - }); - if (icuAvailable) { - visualTestMono("wrapped-rtl4", { 128, 128 }, [&](Rc image) { - Font font{ "noto", 16.f }; - auto t = U"א ב ג ד ה ו ז ח ט י"; - auto run = fontManager->prepare(font, t, 24); - PointF offset = run.alignLines(1.f); - fontManager->testRender(image, run, PointF{ 24, 0 } + PointF{ 3, 20 } + offset, - TestRenderFlags::None, { 3, 3 + 24 }, { 20 }); - }); - } - visualTestMono("wrapped-abc0", { 128, 128 }, [&](Rc image) { - Font font{ "noto", 16.f }; - auto t = U"ABCDEFGHIJKLM N O P Q R S T U V W X Y Z"; - auto run = fontManager->prepare(font, t, 0); - fontManager->testRender(image, run, { 3, 20 }, TestRenderFlags::None, { 3 }, { 20 }); - }); - if (icuAvailable) { - visualTestMono("wrapped-cn", { 490, 384 }, [&](Rc image) { - Font font{ "noto", 32.f }; - auto t = - U"人人生而自由,在尊严和权利上一律平等。他们赋有理性和良心,并应以兄弟关系的精神相对待。"; - auto run = fontManager->prepare(font, t, 460); - fontManager->testRender(image, run, { 3, 36 }, TestRenderFlags::None, { 3, 3 + 460 }, { 36 }); - }); - visualTestMono("wrapped-ar-left", { 490, 384 }, [&](Rc image) { - Font font{ "noto", 32.f }; - // clang-format off - auto t = U"يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. وقد وهبوا عقلاً وضميرًا وعليهم أن يعامل بعضهم بعضًا بروح الإخاء."; - // clang-format on - auto run = fontManager->prepare(font, t, 360); - fontManager->testRender(image, run, { 23, 36 }, TestRenderFlags::None, { 23, 23 + 360 }, { 36 }); - }); - visualTestMono("wrapped-ar-right", { 490, 384 }, [&](Rc image) { - Font font{ "noto", 32.f }; - // clang-format off - auto t = U"يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. وقد وهبوا عقلاً وضميرًا وعليهم أن يعامل بعضهم بعضًا بروح الإخاء."; - // clang-format on - auto run = fontManager->prepare(font, t, 360); - PointF offset = run.alignLines(1.f); - fontManager->testRender(image, run, - RectangleF{ 0, 0, 360, 384 }.at(1, 0.f) + PointF{ 23, 36 } + offset, - TestRenderFlags::None, { 23, 23 + 360 }, { 36 }); - }); - } - visualTestMono("letter-spacing", { 640, 64 }, [&](Rc image) { - Font font{ "noto", 22.f }; - font.letterSpacing = 12.f; - auto t = U"Letter spacing fi fl ff áb́ć"; - auto run = fontManager->prepare(font, t, HUGE_VALF); - fontManager->testRender(image, run, { 5, 36 }); - }); - visualTestMono("word-spacing", { 640, 64 }, [&](Rc image) { - Font font{ "noto", 22.f }; - font.wordSpacing = 12.f; - auto t = U"Word spacing fi fl ff áb́ć"; - auto run = fontManager->prepare(font, t, HUGE_VALF); - fontManager->testRender(image, run, { 5, 36 }); - }); - visualTestMono("letter-spacing-cn", { 640, 64 }, [&](Rc image) { - Font font{ "noto", 22.f }; - font.letterSpacing = 12.f; - auto t = U"人人生而自由,在尊严和权利上一律平等。"; - auto run = fontManager->prepare(font, t, HUGE_VALF); - fontManager->testRender(image, run, { 5, 36 }); - }); - visualTestMono("word-spacing-cn", { 640, 64 }, [&](Rc image) { - Font font{ "noto", 22.f }; - font.wordSpacing = 12.f; - auto t = U"人人生而自由,在尊严和权利上一律平等。"; - auto run = fontManager->prepare(font, t, HUGE_VALF); - fontManager->testRender(image, run, { 5, 36 }); - }); - if (icuAvailable) { - visualTestMono("letter-spacing-ar", { 640, 64 }, [&](Rc image) { - Font font{ "noto", 22.f }; - font.letterSpacing = 12.f; - auto t = U"abcdef مرحبا بالعالم!"; - auto run = fontManager->prepare(font, t, HUGE_VALF); - fontManager->testRender(image, run, { 5, 36 }); - }); - visualTestMono("word-spacing-ar", { 640, 64 }, [&](Rc image) { - Font font{ "noto", 22.f }; - font.wordSpacing = 12.f; - auto t = U"abcdef مرحبا بالعالم!"; - auto run = fontManager->prepare(font, t, HUGE_VALF); - fontManager->testRender(image, run, { 5, 36 }); - }); - } - - visualTestMono("alignment-ltr", { 256, 64 }, [&](Rc image) { - Font font{ "noto", 32.f }; - auto t = U"Hello, world!"; - auto run = fontManager->prepare(font, t); - PointF offset = run.alignLines(0.f, 0.5f); - fontManager->testRender(image, run, - RectangleF(image->bounds().withPadding(2, 2)).at(0, 0.5f) + offset, - TestRenderFlags::TextBounds); - }); - if (icuAvailable) { - visualTestMono("alignment-rtl", { 256, 64 }, [&](Rc image) { - Font font{ "noto", 32.f }; - auto t = U"مرحبا بالعالم!"; - auto run = fontManager->prepare(font, t); - PointF offset = run.alignLines(1.f, 0.5f); - fontManager->testRender(image, run, - RectangleF(image->bounds().withPadding(2, 2)).at(1, 0.5f) + offset, - TestRenderFlags::TextBounds); - }); - } - - visualTestMono("wrapped-align-left", { 256, 256 }, [&](Rc image) { - Font font{ "noto", 22.f }; - auto t = U"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla scelerisque posuere urna " - U"sit amet luctus."; - auto run = fontManager->prepare(font, t, image->width() - 4); - PointF offset = run.alignLines(0.f, 0.5f); - fontManager->testRender(image, run, - RectangleF(image->bounds().withPadding(2, 2)).at(0, 0.5f) + offset, - TestRenderFlags::TextBounds); - }); - visualTestMono("wrapped-align-center", { 256, 256 }, [&](Rc image) { - Font font{ "noto", 22.f }; - auto t = U"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla scelerisque posuere urna " - U"sit amet luctus."; - auto run = fontManager->prepare(font, t, image->width() - 4); - PointF offset = run.alignLines(0.5f, 0.5f); - fontManager->testRender(image, run, - RectangleF(image->bounds().withPadding(2, 2)).at(0.5f, 0.5f) + offset, - TestRenderFlags::TextBounds); - }); - visualTestMono("wrapped-align-right", { 256, 256 }, [&](Rc image) { - Font font{ "noto", 22.f }; - auto t = U"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla scelerisque posuere urna " - U"sit amet luctus."; - auto run = fontManager->prepare(font, t, image->width() - 4); - PointF offset = run.alignLines(1.f, 0.5f); - fontManager->testRender(image, run, - RectangleF(image->bounds().withPadding(2, 2)).at(1, 0.5f) + offset, - TestRenderFlags::TextBounds); - }); - visualTestMono("indented-left", { 256, 256 }, [&](Rc image) { - Font font{ "noto", 22.f }; - auto t = U"0\n 2\n 4\n 2\n 3\n0"; - auto run = fontManager->prepare(font, t, image->width() - 4); - PointF offset = run.alignLines(0, 0.5f); - fontManager->testRender(image, run, - RectangleF(image->bounds().withPadding(2, 2)).at(0, 0.5f) + offset, - TestRenderFlags::TextBounds); - }); - visualTestMono("indented-right", { 256, 256 }, [&](Rc image) { - Font font{ "noto", 22.f }; - auto t = U"0\n 2\n 4\n 2\n 3\n0"; - auto run = fontManager->prepare(font, t, image->width() - 4); - PointF offset = run.alignLines(1, 0.5f); - fontManager->testRender(image, run, - RectangleF(image->bounds().withPadding(2, 2)).at(1, 0.5f) + offset, - TestRenderFlags::TextBounds); - }); - - Font noto24{ "noto", 24 }; - Font noto36{ "noto", 36 }; - Font lato24{ "lato", 24 }; - Font lato26{ "lato", 26 }; - Font lato48{ "lato", 48 }; - Font latolight20{ "latolight", 20.f }; - Font mono20{ "mono", 20 }; - - visualTestMono("richtext1", { 192, 64 }, [&](Rc image) { - FontAndColor fonts[2]{ { noto24 }, { noto36 } }; - uint32_t offsets[1]{ 3 }; - - auto run = fontManager->prepare(TextWithOptions{ utf8ToUtf32("ABCDEF"), TextOptions::Default }, fonts, - offsets); - fontManager->testRender(image, run, { 5, 42 }); - }); - visualTestMono("richtext2", { 256, 64 }, [&](Rc image) { - FontAndColor fonts[3]{ { latolight20 }, { mono20 }, { latolight20 } }; - uint32_t offsets[2]{ 6, 11 }; - - auto run = fontManager->prepare( - TextWithOptions{ utf8ToUtf32("Press Enter to continue"), TextOptions::Default }, fonts, offsets); - fontManager->testRender(image, run, { 5, 42 }); - }); - visualTestMono("richtext-liga", { 192, 64 }, [&](Rc image) { - FontAndColor fonts[2]{ { lato24 }, { lato26 } }; - uint32_t offsets[1]{ 4 }; - - auto run = fontManager->prepare(TextWithOptions{ utf8ToUtf32("fi fi fi"), TextOptions::Default }, - fonts, offsets); - fontManager->testRender(image, run, { 5, 42 }); - }); - visualTestMono("richtext-ml", { 256, 256 }, [&](Rc image) { - FontAndColor fonts[3]{ { lato24 }, { lato48 }, { lato24 } }; - uint32_t offsets[2]{ 24, 25 }; - - auto run = fontManager->prepare( - TextWithOptions{ utf8ToUtf32("Lorem ipsum dolor sit amet, consectetur adipiscing elit"), - TextOptions::Default }, - fonts, offsets, 170); - fontManager->testRender(image, run, { 8, 32 }); - }); - - fontManager.reset(); +TEST_CASE("FontManager document preparation is canonical", "[text-layout]") { + REQUIRE(fonts.has_value()); + REQUIRE(fonts + ->addFontFromFile(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf", + "FontsTest") + .has_value()); + + const ShapedText document = + fonts->shapeText(Font{ "FontsTest", 20.f }, TextWithOptions{ U"Hello, world!" }); + const TextLayout layout = document.layout(); + REQUIRE_FALSE(document.empty()); + REQUIRE_FALSE(layout.empty()); + REQUIRE(layout.bounds().width() > 0.f); } } // namespace Brisk diff --git a/src/graphics/OpenType.cpp b/src/graphics/OpenType.cpp index 3bfc34d5..67574fac 100644 --- a/src/graphics/OpenType.cpp +++ b/src/graphics/OpenType.cpp @@ -24,8 +24,7 @@ namespace Brisk { std::string openTypeFeatureToString(OpenTypeFeature feat) { - uint32_t val = - static_cast(openTypeFeatures[static_cast>(feat)]); + uint32_t val = static_cast(feat); char tag[4]{ static_cast((val >> 24) & 0xFF), static_cast((val >> 16) & 0xFF), @@ -35,51 +34,4 @@ std::string openTypeFeatureToString(OpenTypeFeature feat) { return std::string(tag, 4); } -constexpr hb_tag_t hb_tag(const char (&str)[5]) noexcept { - return HB_TAG(str[0], str[1], str[2], str[3]); -} - -const uint32_t openTypeFeatures[241] = { - hb_tag("aalt"), hb_tag("abvf"), hb_tag("abvm"), hb_tag("abvs"), hb_tag("afrc"), hb_tag("akhn"), - hb_tag("blwf"), hb_tag("blwm"), hb_tag("blws"), hb_tag("calt"), hb_tag("case"), hb_tag("ccmp"), - hb_tag("cfar"), hb_tag("chws"), hb_tag("cjct"), hb_tag("clig"), hb_tag("cpct"), hb_tag("cpsp"), - hb_tag("cswh"), hb_tag("curs"), hb_tag("cv01"), hb_tag("cv02"), hb_tag("cv03"), hb_tag("cv04"), - hb_tag("cv05"), hb_tag("cv06"), hb_tag("cv07"), hb_tag("cv08"), hb_tag("cv09"), hb_tag("cv10"), - hb_tag("cv11"), hb_tag("cv12"), hb_tag("cv13"), hb_tag("cv14"), hb_tag("cv15"), hb_tag("cv16"), - hb_tag("cv17"), hb_tag("cv18"), hb_tag("cv19"), hb_tag("cv20"), hb_tag("cv21"), hb_tag("cv22"), - hb_tag("cv23"), hb_tag("cv24"), hb_tag("cv25"), hb_tag("cv26"), hb_tag("cv27"), hb_tag("cv28"), - hb_tag("cv29"), hb_tag("cv30"), hb_tag("cv31"), hb_tag("cv32"), hb_tag("cv33"), hb_tag("cv34"), - hb_tag("cv35"), hb_tag("cv36"), hb_tag("cv37"), hb_tag("cv38"), hb_tag("cv39"), hb_tag("cv40"), - hb_tag("cv41"), hb_tag("cv42"), hb_tag("cv43"), hb_tag("cv44"), hb_tag("cv45"), hb_tag("cv46"), - hb_tag("cv47"), hb_tag("cv48"), hb_tag("cv49"), hb_tag("cv50"), hb_tag("cv51"), hb_tag("cv52"), - hb_tag("cv53"), hb_tag("cv54"), hb_tag("cv55"), hb_tag("cv56"), hb_tag("cv57"), hb_tag("cv58"), - hb_tag("cv59"), hb_tag("cv60"), hb_tag("cv61"), hb_tag("cv62"), hb_tag("cv63"), hb_tag("cv64"), - hb_tag("cv65"), hb_tag("cv66"), hb_tag("cv67"), hb_tag("cv68"), hb_tag("cv69"), hb_tag("cv70"), - hb_tag("cv71"), hb_tag("cv72"), hb_tag("cv73"), hb_tag("cv74"), hb_tag("cv75"), hb_tag("cv76"), - hb_tag("cv77"), hb_tag("cv78"), hb_tag("cv79"), hb_tag("cv80"), hb_tag("cv81"), hb_tag("cv82"), - hb_tag("cv83"), hb_tag("cv84"), hb_tag("cv85"), hb_tag("cv86"), hb_tag("cv87"), hb_tag("cv88"), - hb_tag("cv89"), hb_tag("cv90"), hb_tag("cv91"), hb_tag("cv92"), hb_tag("cv93"), hb_tag("cv94"), - hb_tag("cv95"), hb_tag("cv96"), hb_tag("cv97"), hb_tag("cv98"), hb_tag("cv99"), hb_tag("c2pc"), - hb_tag("c2sc"), hb_tag("dist"), hb_tag("dlig"), hb_tag("dnom"), hb_tag("dtls"), hb_tag("expt"), - hb_tag("falt"), hb_tag("fin2"), hb_tag("fin3"), hb_tag("fina"), hb_tag("flac"), hb_tag("frac"), - hb_tag("fwid"), hb_tag("half"), hb_tag("haln"), hb_tag("halt"), hb_tag("hist"), hb_tag("hkna"), - hb_tag("hlig"), hb_tag("hngl"), hb_tag("hojo"), hb_tag("hwid"), hb_tag("init"), hb_tag("isol"), - hb_tag("ital"), hb_tag("jalt"), hb_tag("jp78"), hb_tag("jp83"), hb_tag("jp90"), hb_tag("jp04"), - hb_tag("kern"), hb_tag("lfbd"), hb_tag("liga"), hb_tag("ljmo"), hb_tag("lnum"), hb_tag("locl"), - hb_tag("ltra"), hb_tag("ltrm"), hb_tag("mark"), hb_tag("med2"), hb_tag("medi"), hb_tag("mgrk"), - hb_tag("mkmk"), hb_tag("mset"), hb_tag("nalt"), hb_tag("nlck"), hb_tag("nukt"), hb_tag("numr"), - hb_tag("onum"), hb_tag("opbd"), hb_tag("ordn"), hb_tag("ornm"), hb_tag("palt"), hb_tag("pcap"), - hb_tag("pkna"), hb_tag("pnum"), hb_tag("pref"), hb_tag("pres"), hb_tag("pstf"), hb_tag("psts"), - hb_tag("pwid"), hb_tag("qwid"), hb_tag("rand"), hb_tag("rclt"), hb_tag("rkrf"), hb_tag("rlig"), - hb_tag("rphf"), hb_tag("rtbd"), hb_tag("rtla"), hb_tag("rtlm"), hb_tag("ruby"), hb_tag("rvrn"), - hb_tag("salt"), hb_tag("sinf"), hb_tag("size"), hb_tag("smcp"), hb_tag("smpl"), hb_tag("ss01"), - hb_tag("ss02"), hb_tag("ss03"), hb_tag("ss04"), hb_tag("ss05"), hb_tag("ss06"), hb_tag("ss07"), - hb_tag("ss08"), hb_tag("ss09"), hb_tag("ss10"), hb_tag("ss11"), hb_tag("ss12"), hb_tag("ss13"), - hb_tag("ss14"), hb_tag("ss15"), hb_tag("ss16"), hb_tag("ss17"), hb_tag("ss18"), hb_tag("ss19"), - hb_tag("ss20"), hb_tag("ssty"), hb_tag("stch"), hb_tag("subs"), hb_tag("sups"), hb_tag("swsh"), - hb_tag("titl"), hb_tag("tjmo"), hb_tag("tnam"), hb_tag("tnum"), hb_tag("trad"), hb_tag("twid"), - hb_tag("unic"), hb_tag("valt"), hb_tag("vatu"), hb_tag("vchw"), hb_tag("vert"), hb_tag("vhal"), - hb_tag("vjmo"), hb_tag("vkna"), hb_tag("vkrn"), hb_tag("vpal"), hb_tag("vrt2"), hb_tag("vrtr"), - hb_tag("zero"), -}; } // namespace Brisk diff --git a/src/graphics/Path.cpp b/src/graphics/Path.cpp index b3d781f3..ae2a397c 100644 --- a/src/graphics/Path.cpp +++ b/src/graphics/Path.cpp @@ -906,12 +906,19 @@ struct Stroker { } void setPath(const Path& path) { - numPoints = path.points().size(); - numContours = path.segments(); - grow(numPoints, numContours); const auto& elements = path.elements(); const auto& points = path.points(); + // SW_FT_Stroker_ParseOutline expects closed contours to contain an + // explicit copy of their first point. Path::close() does not always + // add a point to Path (when the path is already at its start point), + // and repeated Close elements are valid as well. Reserve one extra + // point for every Close element rather than only one per segment. + const size_t closePoints = std::count(elements.begin(), elements.end(), Path::Element::Close); + numPoints = points.size() + closePoints; + numContours = path.segments(); + grow(numPoints, numContours); + size_t index = 0; for (Path::Element element : elements) { switch (element) { @@ -1000,9 +1007,15 @@ struct Stroker { ft.points = ftPoints.data(); ftTags.reserve(newNumPoints + newNumContours); ft.tags = ftTags.data(); - ftContours.reserve(newNumContours); + // The input outline uses one additional contour slot while a contour + // is being built: moveTo() increments n_contours before initializing + // contours_flag[n_contours], and setPath() writes the final contour + // endpoint after the last contour. The exported outline does not + // need the sentinel, but retaining the extra slot also makes grow() + // safe for both phases. + ftContours.reserve(newNumContours + 1); ft.contours = ftContours.data(); - ftContourFlagMemory.reserve(newNumContours); + ftContourFlagMemory.reserve(newNumContours + 1); ft.contours_flag = ftContourFlagMemory.data(); } diff --git a/src/graphics/RenderStateArgs.hpp b/src/graphics/RenderStateArgs.hpp index 63e32a2c..d13f0d94 100644 --- a/src/graphics/RenderStateArgs.hpp +++ b/src/graphics/RenderStateArgs.hpp @@ -136,7 +136,7 @@ constexpr inline Argument fillColors{}; constexpr inline Argument paintOpacity{}; constexpr inline Argument> linearGradient{}; constexpr inline Argument> radialGradient{}; -constexpr inline Argument> angleGradient{}; +constexpr inline Argument> angularGradient{}; constexpr inline Argument> reflectedGradient{}; constexpr inline Argument multigradient{}; constexpr inline Argument patterns{}; diff --git a/src/graphics/Renderer_test.cpp b/src/graphics/Renderer_test.cpp index 50930fac..02997ab2 100644 --- a/src/graphics/Renderer_test.cpp +++ b/src/graphics/Renderer_test.cpp @@ -85,10 +85,10 @@ TEST_CASE("Renderer devices", "[gpu]") { TEST_CASE("Renderer - fonts") { auto ttf = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf"); REQUIRE(ttf.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Regular, *ttf, true, FontFlags::Default); + fonts->addFont(*ttf, "Lato"); auto ttf2 = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Heavy.ttf"); REQUIRE(ttf2.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Bold, *ttf2, true, FontFlags::Default); + fonts->addFont(*ttf2, "Lato"); for (bool subpixel : { false, true }) { renderTest( @@ -122,10 +122,10 @@ TEST_CASE("Renderer - fonts") { TEST_CASE("Html text") { auto ttf = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf"); REQUIRE(ttf.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Regular, *ttf, true, FontFlags::Default); + fonts->addFont(*ttf, "Lato"); auto ttf2 = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Heavy.ttf"); REQUIRE(ttf2.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Bold, *ttf2, true, FontFlags::Default); + fonts->addFont(*ttf2, "Lato"); renderTest("html-text", Size{ 300, 150 }, [](RenderContext& context) { Canvas canvas(context); @@ -304,10 +304,10 @@ TEST_CASE("Canvas::drawImage", "[gpu]") { TEST_CASE("Emoji") { auto ttf = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "NotoColorEmoji-SVG.otf"); REQUIRE(ttf.has_value()); - fonts->addFont("Noto Emoji", FontStyle::Normal, FontWeight::Regular, *ttf, true, FontFlags::EnableColor); + fonts->addFont(*ttf, "Noto Emoji"); auto ttf2 = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf"); REQUIRE(ttf2.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Regular, *ttf2, true, FontFlags::Default); + fonts->addFont(*ttf2, "Lato"); const Size size{ 1200, 200 }; renderTest("emoji-only", size, [&](RenderContext& context) { @@ -520,7 +520,7 @@ template <> inline constexpr std::initializer_list> defaultNames{ { "linear", GradientType::Linear }, { "radial", GradientType::Radial }, - { "angle", GradientType::Angle }, + { "angular", GradientType::Angular }, { "reflected", GradientType::Reflected }, }; using enum TestMode; @@ -854,7 +854,7 @@ TEST_CASE("JoinStyle") { TEST_CASE("GradientType") { for (GradientType gradientType : - { GradientType::Linear, GradientType::Radial, GradientType::Angle, GradientType::Reflected }) { + { GradientType::Linear, GradientType::Radial, GradientType::Angular, GradientType::Reflected }) { renderTest("canvas-gradientType-" + fmt::to_string(gradientType), Size{ 320, 320 }, [gradientType](RenderContext& context) { @@ -1022,7 +1022,7 @@ TEST_CASE("WebGPU") { } #endif -TEST_CASE("Canvas-Performance") { +TEST_CASE("Canvas-Performance", "[.performance]") { for (int i = 128; i <= 2048; i *= 2) { renderTest("canvas-border-" + std::to_string(i), Size{ i, i }, [i](RenderContext& context) { Canvas canvas(context); @@ -1043,7 +1043,7 @@ TEST_CASE("Canvas-Performance") { } } -TEST_CASE("Layers") { +TEST_CASE("Layers", "[.performance]") { bool linearColorSaved = linearColor; linearColor = true; static Size canvasSize{ 640, 320 }; @@ -1080,7 +1080,7 @@ TEST_CASE("Backlayer") { #endif auto ttf = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf"); REQUIRE(ttf.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Regular, *ttf, true, FontFlags::Default); + fonts->addFont(*ttf, "Lato"); bool linearColorSaved = linearColor; linearColor = true; @@ -1142,10 +1142,10 @@ constexpr std::array compModes = { CompositionMode::Plus, CompositionMode::PlusLighter, }; -TEST_CASE("Composition") { +TEST_CASE("Composition", "[.performance]") { auto ttf = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf"); REQUIRE(ttf.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Regular, *ttf, true, FontFlags::Default); + fonts->addFont(*ttf, "Lato"); Size cellSize{ 100, 100 }; Size imageSize(25 + cellSize.width * blendModes.size(), 25 + cellSize.height * compModes.size()); @@ -1286,7 +1286,7 @@ TEST_CASE("Matrix invariants") { auto ttf = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf"); REQUIRE(ttf.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Regular, *ttf, true, FontFlags::Default); + fonts->addFont(*ttf, "Lato"); renderTest("matrix-invariants-text", Size{ 256, 128 }, [](RenderContext& context) { @@ -1303,7 +1303,7 @@ TEST_CASE("Matrix invariants") { auto ttf2 = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "NotoColorEmoji-SVG.otf"); REQUIRE(ttf2.has_value()); - fonts->addFont("Noto Emoji", FontStyle::Normal, FontWeight::Regular, *ttf2, true, FontFlags::EnableColor); + fonts->addFont(*ttf2, "Noto Emoji"); renderTest("matrix-invariants-emoji", Size{ 256, 128 }, [](RenderContext& context) { @@ -1322,7 +1322,7 @@ TEST_CASE("Matrix invariants") { TEST_CASE("Text subpixel alignment") { auto ttf = readBytes(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Light.ttf"); REQUIRE(ttf.has_value()); - fonts->addFont("Lato", FontStyle::Normal, FontWeight::Regular, *ttf, true, FontFlags::Default); + fonts->addFont(*ttf, "Lato"); renderTest("text-subpixel-alignment", Size{ 256, 440 }, [](RenderContext& context) { Canvas canvas(context); diff --git a/src/graphics/TextEngine/BreakIterator.cpp b/src/graphics/TextEngine/BreakIterator.cpp new file mode 100644 index 00000000..fa600b61 --- /dev/null +++ b/src/graphics/TextEngine/BreakIterator.cpp @@ -0,0 +1,342 @@ +#include "text_layout/BreakIterator.hpp" + +#include +#include +extern "C" { +#include +} +#include + +#include +#include +#include + +namespace Brisk::TextEngine { + +void BreakIterator::setText(std::u32string_view text) { + const size_t clampedSize = std::min(text.size(), maxTextLength); + m_text = text.substr(0, clampedSize); + m_lastBreakPos = unstarted; + doSetText(); +} + +void BreakIterator::reset() { + m_lastBreakPos = unstarted; + doSetText(); +} + +uint32_t BreakIterator::countBreaks() { + reset(); + uint32_t count = 0; + while (nextBreak() != end) { + ++count; + } + reset(); + return count; +} + +uint32_t BreakIterator::nextBreak() { + if (m_lastBreakPos == unstarted) { + m_lastBreakPos = 0; + return 0; + } + uint32_t b = doNextBreak(); + m_lastBreakPos = b; + return b; +} + +std::u32string_view BreakIterator::nextFragment() { + if (m_lastBreakPos == unstarted) { + uint32_t first = nextBreak(); + if (first == end) { + return {}; + } + } + + if (m_lastBreakPos == end) { + return {}; + } + + uint32_t start = m_lastBreakPos; + uint32_t endPos = nextBreak(); + if (endPos == end) { + return {}; + } + + if (start >= m_text.size() || endPos < start) { + return {}; + } + + return m_text.substr(start, endPos - start); +} + +LineBreakIterator::LineBreakIterator(std::string lang, Mode mode) : m_lang(std::move(lang)), m_mode(mode) {} + +void LineBreakIterator::doSetText() { + m_breaks.clear(); + m_currentIndex = 0; + + const uint32_t len = static_cast(m_text.size()); + + if (len == 0) { + return; + } + + std::vector brks(len); + const char* langPtr = m_lang.empty() ? nullptr : m_lang.c_str(); + + set_linebreaks_utf32(reinterpret_cast(m_text.data()), len, langPtr, brks.data()); + + for (uint32_t i = 0; i < len; ++i) { + const bool isBreak = (brks[i] == LINEBREAK_MUSTBREAK) || + (m_mode == Mode::MandatoryAndAllowed && brks[i] == LINEBREAK_ALLOWBREAK); + if (isBreak) { + uint32_t breakPos = i + 1; + if (breakPos < len) { + m_breaks.push_back(breakPos); + } + } + } + + m_breaks.push_back(len); +} + +uint32_t LineBreakIterator::countBreaks() { + reset(); + return static_cast(1 + m_breaks.size()); +} + +uint32_t LineBreakIterator::doNextBreak() { + if (m_currentIndex < m_breaks.size()) { + return m_breaks[m_currentIndex++]; + } + return UINT32_MAX; +} + +void GraphemeBreakIterator::doSetText() { + m_state = 0; +} + +uint32_t GraphemeBreakIterator::doNextBreak() { + const uint32_t len = static_cast(m_text.size()); + + if (m_lastBreakPos >= len) { + return UINT32_MAX; + } + + uint32_t pos = m_lastBreakPos; + while (pos < len - 1) { + utf8proc_int32_t cp1 = static_cast(m_text[pos]); + utf8proc_int32_t cp2 = static_cast(m_text[pos + 1]); + ++pos; + if (utf8proc_grapheme_break_stateful(cp1, cp2, &m_state)) { + return pos; + } + } + + return len; +} + +void ParagraphBreakIterator::doSetText() {} + +uint32_t ParagraphBreakIterator::doNextBreak() { + const uint32_t len = static_cast(m_text.size()); + + if (m_lastBreakPos >= len) { + return UINT32_MAX; + } + + uint32_t pos = m_lastBreakPos; + while (pos < len) { + char32_t cp = m_text[pos]; + if (cp == 0x000D) { // CR + // Lookahead for CRLF + if (pos + 1 < len && m_text[pos + 1] == 0x000A) { + pos += 2; + } else { + pos += 1; + } + return pos; + } + if (cp == 0x000A || (cp >= 0x001C && cp <= 0x001E) || cp == 0x0085 || cp == 0x2029) { + pos += 1; + return pos; + } + ++pos; + } + + return len; +} + +namespace { + +constexpr BiDiRun emptyRun{}; + +static bool isWordCharacter(char32_t cp) { + utf8proc_category_t cat = utf8proc_category(static_cast(cp)); + switch (cat) { + case UTF8PROC_CATEGORY_LU: // Letter, uppercase + case UTF8PROC_CATEGORY_LL: // Letter, lowercase + case UTF8PROC_CATEGORY_LT: // Letter, titlecase + case UTF8PROC_CATEGORY_LM: // Letter, modifier + case UTF8PROC_CATEGORY_LO: // Letter, other + case UTF8PROC_CATEGORY_ND: // Number, decimal digit + case UTF8PROC_CATEGORY_NL: // Number, letter + case UTF8PROC_CATEGORY_NO: // Number, other + return true; + default: + return false; + } +} + +} // namespace + +void WordBreakIterator::doSetText() { + m_breaks.clear(); + m_currentIndex = 0; + + const uint32_t len = static_cast(m_text.size()); + if (len == 0) { + return; + } + + std::vector breaks(len); + set_wordbreaks_utf32(reinterpret_cast(m_text.data()), len, nullptr, breaks.data()); + + for (uint32_t i = 0; i + 1 < len; ++i) { + if (breaks[i] == WORDBREAK_BREAK && isWordCharacter(m_text[i + 1])) { + m_breaks.push_back(i + 1); + } + } + m_breaks.push_back(len); +} + +uint32_t WordBreakIterator::countBreaks() { + reset(); + return static_cast(1 + m_breaks.size()); +} + +uint32_t WordBreakIterator::doNextBreak() { + if (m_currentIndex < m_breaks.size()) { + return m_breaks[m_currentIndex++]; + } + return UINT32_MAX; +} + +BiDiIterator::BiDiIterator(BaseDirection baseDirection) : m_baseDirection(baseDirection) {} + +void BiDiIterator::setText(std::u32string_view text, BaseDirection baseDirection) { + m_baseDirection = baseDirection; + BreakIterator::setText(text); +} + +const BiDiRun& BiDiIterator::nextRun() { + if (m_lastBreakPos == unstarted) { + nextBreak(); + } + if (m_currentRunIndex < m_runs.size()) { + const BiDiRun& run = m_runs[m_currentRunIndex]; + nextBreak(); + return run; + } + return emptyRun; +} + +const BiDiRun& BiDiIterator::currentRun() const noexcept { + if (m_lastBreakPos != end && m_currentRunIndex > 0 && m_currentRunIndex <= m_runs.size()) { + return m_runs[m_currentRunIndex - 1]; + } + return emptyRun; +} + +std::u32string_view BiDiIterator::currentFragment() const noexcept { + const BiDiRun& run = currentRun(); + if (run.range.empty() || run.range.min >= m_text.size()) { + return {}; + } + return m_text.substr(run.range.min, run.range.distance()); +} + +void BiDiIterator::doSetText() { + m_runs.clear(); + m_currentRunIndex = 0; + m_paragraphLevel = 0; + + const uint32_t len = static_cast(m_text.size()); + if (len == 0) { + if (m_baseDirection == BaseDirection::RightToLeft || m_baseDirection == BaseDirection::DefaultRTL) { + m_paragraphLevel = 1; + } else { + m_paragraphLevel = 0; + } + return; + } + + SBCodepointSequence codepoints; + codepoints.stringEncoding = SBStringEncodingUTF32; + codepoints.stringBuffer = const_cast(static_cast(m_text.data())); + codepoints.stringLength = len; + + SBAlgorithmRef algorithm = SBAlgorithmCreate(&codepoints); + if (!algorithm) { + return; + } + + SBLevel sbBaseLevel = SBLevelDefaultLTR; + switch (m_baseDirection) { + case BaseDirection::DefaultLTR: + sbBaseLevel = SBLevelDefaultLTR; + break; + case BaseDirection::DefaultRTL: + sbBaseLevel = SBLevelDefaultRTL; + break; + case BaseDirection::LeftToRight: + sbBaseLevel = 0; + break; + case BaseDirection::RightToLeft: + sbBaseLevel = 1; + break; + } + + SBParagraphRef paragraph = SBAlgorithmCreateParagraph(algorithm, 0, len, sbBaseLevel); + if (paragraph) { + m_paragraphLevel = static_cast(SBParagraphGetBaseLevel(paragraph)); + + SBLineRef line = SBParagraphCreateLine(paragraph, 0, len); + if (line) { + SBUInteger runCount = SBLineGetRunCount(line); + const SBRun* sbRuns = SBLineGetRunsPtr(line); + + m_runs.reserve(runCount); + for (SBUInteger i = 0; i < runCount; ++i) { + BiDiRun run; + const auto offset = static_cast(sbRuns[i].offset); + run.range = Range{ offset, offset + static_cast(sbRuns[i].length) }; + run.visualIndex = static_cast(i); + run.level = static_cast(sbRuns[i].level); + m_runs.push_back(run); + } + + // Ensure runs are in logical order sorted by offset + std::sort(m_runs.begin(), m_runs.end(), [](const BiDiRun& a, const BiDiRun& b) { + return a.range.min < b.range.min; + }); + + SBLineRelease(line); + } + + SBParagraphRelease(paragraph); + } + + SBAlgorithmRelease(algorithm); +} + +uint32_t BiDiIterator::doNextBreak() { + if (m_currentRunIndex < m_runs.size()) { + const auto& run = m_runs[m_currentRunIndex++]; + return run.range.max; + } + return UINT32_MAX; +} + +} // namespace Brisk::TextEngine diff --git a/src/graphics/TextEngine/CMakeLists.txt b/src/graphics/TextEngine/CMakeLists.txt new file mode 100644 index 00000000..49162243 --- /dev/null +++ b/src/graphics/TextEngine/CMakeLists.txt @@ -0,0 +1,53 @@ +# +# Brisk TextEngine +# +# Brisk TextEngine implementation and its external text-layout dependencies. +# + +set(BRISK_TEXT_ENGINE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + +if (NOT EXISTS "${BRISK_TEXT_ENGINE_SOURCE_DIR}/Layout.cpp") + message(FATAL_ERROR "Brisk TextEngine sources are missing from ${BRISK_TEXT_ENGINE_SOURCE_DIR}") +endif () + +find_package(freetype CONFIG REQUIRED) +find_package(harfbuzz CONFIG REQUIRED) +if (NOT TARGET unibreak AND NOT TARGET libunibreak::libunibreak) + find_package(libunibreak CONFIG REQUIRED) +endif () +if (NOT TARGET SheenBidi::SheenBidi) + find_package(SheenBidi CONFIG REQUIRED) +endif () +find_package(unofficial-utf8proc CONFIG REQUIRED) + +add_library(brisk-text-engine STATIC + ${BRISK_TEXT_ENGINE_SOURCE_DIR}/BreakIterator.cpp + ${BRISK_TEXT_ENGINE_SOURCE_DIR}/FontDatabase.cpp + ${BRISK_TEXT_ENGINE_SOURCE_DIR}/Layout.cpp + ${BRISK_TEXT_ENGINE_SOURCE_DIR}/include/text_layout/BreakIterator.hpp + ${BRISK_TEXT_ENGINE_SOURCE_DIR}/include/text_layout/FontDatabase.hpp + ${BRISK_TEXT_ENGINE_SOURCE_DIR}/include/text_layout/Layout.hpp + ${BRISK_TEXT_ENGINE_SOURCE_DIR}/include/text_layout/Types.hpp) + +set_property(TARGET brisk-text-engine PROPERTY EXPORT_NAME TextEngine) +add_library(Brisk::TextEngine ALIAS brisk-text-engine) + +target_include_directories(brisk-text-engine + PUBLIC + $ + PRIVATE + ${BRISK_TEXT_ENGINE_SOURCE_DIR}/src) + +target_link_libraries(brisk-text-engine + PUBLIC + brisk-core + PRIVATE + freetype + harfbuzz + utf8proc + libunibreak::libunibreak + SheenBidi::SheenBidi +) + +target_compile_features(brisk-text-engine PRIVATE cxx_std_20) +target_compile_definitions(brisk-text-engine PRIVATE TL_BUILDING_LIBRARY=1) diff --git a/src/graphics/TextEngine/FontDatabase.cpp b/src/graphics/TextEngine/FontDatabase.cpp new file mode 100644 index 00000000..c21fbb8e --- /dev/null +++ b/src/graphics/TextEngine/FontDatabase.cpp @@ -0,0 +1,818 @@ +#include "text_layout/FontDatabase.hpp" + +#include + +#include +#include +#include +#include FT_FREETYPE_H +#include FT_MODULE_H +#include FT_MULTIPLE_MASTERS_H +#include FT_SIZES_H +#include FT_TRUETYPE_TABLES_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Brisk::TextEngine { + +namespace { + +// OpenType features that HarfBuzz enables by default (or that a font +// commonly ships) and that are capable of changing glyph selection +// (GSUB) or glyph positioning (GPOS) even for a plain Latin/Common +// script run: +// +// ccmp - glyph composition/decomposition +// liga - standard ligatures (fi, fl, ffi, ...) +// clig - contextual ligatures +// rlig - required ligatures +// calt - contextual alternates +// rclt - required contextual alternates +// locl - localized forms +// kern - pair kerning (GPOS) +// mark - mark-to-base attachment +// mkmk - mark-to-mark attachment +// curs - cursive attachment +// dist - minimum distance adjustment +// rvrn - required variation alternates (variable fonts) +// +// Presence of ANY of these for the "latn" or "DFLT" script is treated +// as disqualifying, regardless of whether it actually touches an +// ASCII glyph. This is deliberately conservative: a false negative +// (falling back to full HarfBuzz shaping unnecessarily) just costs +// some CPU. A false positive (wrongly taking the fast path) would +// produce a visible, hard-to-diagnose shaping bug. Given the huge +// asymmetry in cost, we always round down to "not safe" when unsure. +inline constexpr std::array shapingSensitiveFeatures = { + "ccmp", "liga", "clig", "rlig", "calt", "rclt", "locl", "kern", "mark", "mkmk", "curs", "dist", "rvrn", +}; + +inline bool tableHasFeatureForScript(hb_face_t* hbFace, hb_tag_t tableTag, hb_tag_t featureTag) { + static constexpr hb_tag_t scriptsToCheck[] = { + HB_TAG('l', 'a', 't', 'n'), + HB_TAG('D', 'F', 'L', 'T'), + }; + + for (hb_tag_t scriptTag : scriptsToCheck) { + unsigned int scriptIndex = 0; + if (!hb_ot_layout_table_find_script(hbFace, tableTag, scriptTag, &scriptIndex)) { + continue; // font has no entry for this script in this table + } + + // Check the script's default LangSys (what an unlabeled ASCII + // run resolves to — we don't have a specific BCP-47 language + // tag at the text-classification stage). + unsigned int featureIndex = 0; + if (hb_ot_layout_language_find_feature(hbFace, tableTag, scriptIndex, + HB_OT_LAYOUT_DEFAULT_LANGUAGE_INDEX, featureTag, + &featureIndex)) { + return true; + } + } + return false; +} + +// Legacy pre-OpenType TrueType 'kern' table. HarfBuzz uses it as a +// kerning fallback when GPOS has no applicable pair-kerning lookups. +// If it exists and is non-empty, ASCII pair spacing may still be +// adjusted even though no GPOS feature fired. +inline bool hasLegacyKernTable(FT_Face ftFace) { + if (!FT_HAS_KERNING(ftFace)) { + return false; + } + + FT_ULong len = 0; + // Passing a null buffer makes FreeType just report the table + // length without copying data. + FT_Error err = FT_Load_Sfnt_Table(ftFace, FT_MAKE_TAG('k', 'e', 'r', 'n'), 0, nullptr, &len); + return err == FT_Err_Ok && len > 0; +} + +// Returns true if it is safe to render ASCII text (0x20-0x7E) for +// `ftFace` using a direct cmap+advance path (one glyph per codepoint, +// hmtx advance) instead of full HarfBuzz shaping. Call this ONCE per +// loaded font (cache the bool alongside the font object); do not call +// per-frame or per-string. +inline bool isAsciiFastShapingSafe(FT_Face ftFace) { + if (ftFace == nullptr) { + return false; + } + + if (hasLegacyKernTable(ftFace)) { + return false; + } + + // hb_ft_face_create_referenced keeps its own refcount on ftFace; + // it does not take ownership away from FreeType. + hb_face_t* hbFace = hb_ft_face_create_referenced(ftFace); + if (hbFace == nullptr) { + return false; + } + + bool sensitiveFeatureFound = false; + for (const char* featureStr : shapingSensitiveFeatures) { + hb_tag_t featureTag = HB_TAG(featureStr[0], featureStr[1], featureStr[2], featureStr[3]); + if (tableHasFeatureForScript(hbFace, HB_OT_TAG_GSUB, featureTag) || + tableHasFeatureForScript(hbFace, HB_OT_TAG_GPOS, featureTag)) { + sensitiveFeatureFound = true; + break; + } + } + + hb_face_destroy(hbFace); + return !sensitiveFeatureFound; +} + +constexpr Range kAsciiRange{ 0x20, 0x7F }; // 0x7F is exclusive + +inline bool fontHasGlyphs(FT_Face ftFace, Range range) { + if (ftFace == nullptr) { + return false; + } + + // FT_Get_Char_Index is a direct cmap lookup. Enumerating the complete cmap + // with FT_Get_First_Char/FT_Get_Next_Char would usually inspect thousands + // of entries just to answer this small, fixed ASCII query. + const FT_ULong first = static_cast(range.min); + const FT_ULong last = static_cast(range.max); + for (FT_ULong codepoint = first; codepoint < last; ++codepoint) { + if (FT_Get_Char_Index(ftFace, codepoint) == 0) [[unlikely]] { + return false; + } + } + return true; +} + +/// Converts a hinting policy to the FreeType load flags to use for glyph loading. +constexpr FT_Int32 hintingLoadFlags(Hinting hinting) { + switch (hinting) { + case Hinting::Disable: + return FT_LOAD_NO_HINTING; + case Hinting::Enable: + return FT_LOAD_DEFAULT; + case Hinting::Auto: + default: + return FT_LOAD_TARGET_LIGHT; + } +} + +constexpr size_t kMaxVariationAxes = 12; + +/// FT_Done_Face calls from FaceEntry destructors, which may run after the owning +/// database has been destroyed (handles can outlive it via PreparedDocument). +size_t s_ftDoneFaceCalls = 0; + +// Branchless ASCII lowercase (only touches 'A'-'Z', leaves everything else untouched). +[[nodiscard]] inline char ascii_to_lower(char c) noexcept { + unsigned char uc = static_cast(c); + unsigned char is_upper = static_cast(uc - 'A') < 26u; + return static_cast(uc | (is_upper << 5)); // OR with 0x20 if uppercase +} + +// strcmp-style three-way compare, case-insensitive, ASCII-only. +// Returns <0, 0, >0 like strcmp. +[[nodiscard]] inline int compare_ci(std::string_view a, std::string_view b) noexcept { + const char* pa = a.data(); + const char* pb = b.data(); + const std::size_t n = a.size() < b.size() ? a.size() : b.size(); + + for (std::size_t i = 0; i < n; ++i) { + unsigned char ca = static_cast(ascii_to_lower(pa[i])); + unsigned char cb = static_cast(ascii_to_lower(pb[i])); + if (ca != cb) + return static_cast(ca) - static_cast(cb); + } + // Common prefix equal -> shorter string is "less". + return static_cast(a.size()) - static_cast(b.size()); +} + +// Convenience predicate for use with std::sort / std::map / etc. +[[nodiscard]] inline bool less_ci(std::string_view a, std::string_view b) noexcept { + return compare_ci(a, b) < 0; +} + +[[nodiscard]] inline bool equal_ci(std::string_view a, std::string_view b) noexcept { + return compare_ci(a, b) == 0; +} + +struct VariationCoordinate { + FT_ULong tag{}; + FT_Fixed value{}; + auto operator<=>(const VariationCoordinate&) const = default; +}; + +using NormalizedVariations = inline_vector; + +struct FontFileRecord { + uint32_t id{}; + std::filesystem::path path; + /// Borrowed font bytes for memory-registered fonts (registerFont). When set, + /// faces are opened with FT_New_Memory_Face and the caller must keep the + /// buffer alive and unmodified for as long as the database is used. + const std::byte* data{}; + size_t dataSize{}; + FT_Long faceIndex{}; + std::string familyName; + bool italic{}; + uint16_t weight{}; + bool asciiFastShapingSafe{}; + bool asciiRangePresent{}; + + struct VariationAxis { + FT_ULong tag{}; + FT_Fixed minimum{}; + FT_Fixed defaultValue{}; + FT_Fixed maximum{}; + }; + + inline_vector variationAxes; +}; + +struct FaceKey { + uint32_t fileId{}; + FT_Long faceIndex{}; + NormalizedVariations variations; + auto operator<=>(const FaceKey&) const = default; +}; + +struct SizeKey { + FT_F26Dot6 pixels{}; + FT_Int32 loadFlags{}; + auto operator<=>(const SizeKey&) const = default; +}; + +struct LibraryOwner { + FT_Library library{}; + std::shared_ptr sharedLibrary; + + ~LibraryOwner() { + // A shared library is owned by the caller. Keeping sharedLibrary alive + // is the reference-counting mechanism for the external FT_Library; + // only independently-created libraries are destroyed here. + if (sharedLibrary == nullptr && library != nullptr) { + FT_Done_FreeType(library); + } + } + + explicit LibraryOwner() = default; + + explicit LibraryOwner(std::shared_ptr owner, void* sharedLibrary) + : library(static_cast(sharedLibrary)), sharedLibrary(std::move(owner)) {} +}; + +struct FaceEntry { + std::shared_ptr libraryOwner; + FT_Face ftFace{}; + FT_Size activeSize{}; + + ~FaceEntry() { + if (ftFace != nullptr) { + FT_Done_Face(ftFace); + ++s_ftDoneFaceCalls; + } + } +}; + +} // namespace + +namespace detail { + +/// Allocates a stable process-wide identity for a font instance. +uint64_t nextFontInstanceId() { + static std::atomic next{ 1 }; + return next.fetch_add(1, std::memory_order_relaxed); +} + +class FontInstance { +public: + std::shared_ptr face; + FT_Size ftSize{}; + hb_font_t* hbFont{}; + FT_F26Dot6 pixels{}; + FT_Int32 loadFlags{}; + bool asciiRangePresent{}; + uint64_t instanceId{}; + + ~FontInstance() { + if (face != nullptr && face->activeSize == ftSize) { + face->activeSize = nullptr; + } + if (hbFont != nullptr) { + hb_font_destroy(hbFont); + } + if (ftSize != nullptr) { + FT_Done_Size(ftSize); + } + } +}; + +} // namespace detail + +class DefaultFontDatabase final : public FontDatabase { +public: + explicit DefaultFontDatabase(std::shared_ptr owner = {}, void* sharedLibrary = nullptr) { + if (owner != nullptr && sharedLibrary != nullptr) { + m_libraryOwner = std::make_shared(std::move(owner), sharedLibrary); + return; + } + m_libraryOwner = std::make_shared(); + FT_Init_FreeType(&m_libraryOwner->library); + } + + ~DefaultFontDatabase() override { + m_recentInstances = {}; + m_instanceLookup.clear(); + m_faces.clear(); + m_libraryOwner.reset(); + } + + FontHandle resolveFont(const FontDef& fontDef) const override { + for (std::string_view family : fontDef.familyNames) { + if (FontHandle handle = resolveFont(family, fontDef.style, fontDef.weight, fontDef.fontSize, + fontDef.variations, fontDef.hinting)) { + return handle; + } + } + return {}; + } + + FontHandle resolveFont(std::string_view familyName, FontStyle style, FontWeight weight, + LayoutUnit fontSize, std::span variations, + Hinting hinting) const override { + if (m_libraryOwner == nullptr || m_libraryOwner->library == nullptr || fontSize <= kZero) { + return {}; + } + + const FontFileRecord* record = matchFont(familyName, style, weight); + if (record == nullptr) { + return {}; + } + + const auto normalized = normalizeVariations(*record, variations); + if (!normalized) { + return {}; + } + + FaceKey faceKey{ record->id, record->faceIndex, *normalized }; + std::shared_ptr face = getOrCreateFace(faceKey, *record); + if (face == nullptr) { + return {}; + } + + const FT_F26Dot6 pixels = static_cast(to26Dot6(fontSize)); + if (pixels <= 0) { + return {}; + } + const SizeKey sizeKey{ pixels, hintingLoadFlags(hinting) }; + const auto instanceKey = std::make_pair(faceKey, sizeKey); + if (auto found = m_instanceLookup.find(instanceKey); found != m_instanceLookup.end()) { + if (FontHandle handle = found->second.lock()) { + remember(handle); + return handle; + } + m_instanceLookup.erase(found); + } + + FontHandle handle = createInstance(std::move(face), sizeKey, record->asciiRangePresent); + if (!handle) { + return {}; + } + m_instanceLookup.emplace(instanceKey, handle); + remember(handle); + return handle; + } + + ActiveFont activate(const FontHandle& handle) const override { + if (!belongsToThisDatabase(handle)) { + return {}; + } + FaceEntry& face = *handle->face; + if (face.activeSize != handle->ftSize) { + if (FT_Activate_Size(handle->ftSize) != 0) { + return {}; + } + face.activeSize = handle->ftSize; + // hb-ft derives scale/state from FT_Face. A cached size switch is the only + // GUI state change, so synchronize this font only when that switch occurs. + hb_ft_font_changed(handle->hbFont); + } + return ActiveFont{ static_cast(face.ftFace), static_cast(handle->hbFont), + static_cast(handle->loadFlags), handle->asciiRangePresent, + handle->instanceId }; + } + + FontCacheStats cacheStats() const noexcept override { + size_t liveFaces = 0; + for (const auto& [key, face] : m_faces) { + liveFaces += !face.expired(); + } + size_t liveInstances = 0; + for (const auto& [key, instance] : m_instanceLookup) { + liveInstances += !instance.expired(); + } + return FontCacheStats{ liveFaces, liveInstances, m_sizeInitializations, + liveInstances, m_ftNewFaceCalls, m_ftDoneFaceCalls + s_ftDoneFaceCalls }; + } + + /// Registers all faces of an in-memory font file without copying the bytes. + std::vector registerFont(std::span data) override { + std::vector families; + if (m_libraryOwner == nullptr || m_libraryOwner->library == nullptr || data.empty()) { + return families; + } + + FT_Face first = nullptr; + ++m_ftNewFaceCalls; + if (FT_New_Memory_Face(m_libraryOwner->library, reinterpret_cast(data.data()), + static_cast(data.size()), 0, &first) != 0) { + return families; + } + const FT_Long faceCount = first->num_faces; + FT_Done_Face(first); + ++m_ftDoneFaceCalls; + + for (FT_Long faceIndex = 0; faceIndex < faceCount; ++faceIndex) { + FT_Face face = nullptr; + ++m_ftNewFaceCalls; + if (FT_New_Memory_Face(m_libraryOwner->library, reinterpret_cast(data.data()), + static_cast(data.size()), faceIndex, &face) != 0) { + continue; + } + FontFileRecord record; + record.id = ++m_uniqueFontFileId; + record.data = data.data(); + record.dataSize = data.size(); + record.faceIndex = faceIndex; + if (face->family_name != nullptr) { + record.familyName = face->family_name; + families.push_back(record.familyName); + } + record.italic = (face->style_flags & FT_STYLE_FLAG_ITALIC) != 0; + TT_OS2* os2 = static_cast(FT_Get_Sfnt_Table(face, FT_SFNT_OS2)); + record.weight = os2 != nullptr && os2->version != 0xFFFF + ? static_cast(os2->usWeightClass) + : ((face->style_flags & FT_STYLE_FLAG_BOLD) != 0 + ? static_cast(FontWeight::Bold) + : static_cast(FontWeight::Regular)); + FT_MM_Var* mm = nullptr; + if (FT_Get_MM_Var(face, &mm) == 0 && mm != nullptr) { + const FT_UInt axisCount = std::min(mm->num_axis, static_cast(kMaxVariationAxes)); + for (FT_UInt i = 0; i < axisCount; ++i) { + record.variationAxes.push_back( + { mm->axis[i].tag, mm->axis[i].minimum, mm->axis[i].def, mm->axis[i].maximum }); + } + FT_Done_MM_Var(m_libraryOwner->library, mm); + } + record.asciiFastShapingSafe = isAsciiFastShapingSafe(face); + record.asciiRangePresent = fontHasGlyphs(face, kAsciiRange); + FT_Done_Face(face); + ++m_ftDoneFaceCalls; + m_fontFiles.push_back(std::move(record)); + } + + if (faceCount > 0) { + std::sort(m_fontFiles.begin(), m_fontFiles.end(), + [](const FontFileRecord& a, const FontFileRecord& b) { + return less_ci(a.familyName, b.familyName); + }); + } + return families; + } + + static int matchScore(const FontFileRecord& record, FontStyle style, FontWeight weight) { + const int styleScore = record.italic == (style == FontStyle::Italic) ? 2000 : 0; + const int distance = std::abs(static_cast(record.weight) - static_cast(weight)); + return styleScore + 1000 - std::min(distance, 1000); + } + + const FontFileRecord* matchFont(std::string_view family, FontStyle style, FontWeight weight) const { + struct FontFileRecordComparator { + bool operator()(const FontFileRecord& record, std::string_view name) const noexcept { + return less_ci(record.familyName, name); + } + + bool operator()(std::string_view name, const FontFileRecord& record) const noexcept { + return less_ci(name, record.familyName); + } + }; + + const auto [rangeBegin, rangeEnd] = + std::equal_range(m_fontFiles.begin(), m_fontFiles.end(), family, FontFileRecordComparator{}); + + if (rangeBegin == rangeEnd) { + // Not a registered family: follow aliases (one hop; aliases always point at + // real families, so no cycle can occur). + if (const auto it = findAlias(family); it != m_aliases.end()) { + const auto [aliasBegin, aliasEnd] = std::equal_range(m_fontFiles.begin(), m_fontFiles.end(), + it->second, FontFileRecordComparator{}); + return bestInRange(aliasBegin, aliasEnd, style, weight); + } + return nullptr; + } + + return bestInRange(rangeBegin, rangeEnd, style, weight); + } + + template + const FontFileRecord* bestInRange(Iterator first, Iterator last, FontStyle style, + FontWeight weight) const { + const FontFileRecord* best = nullptr; + int bestScore = -1; + for (auto it = first; it != last; ++it) { + const int score = matchScore(*it, style, weight); + if (score > bestScore) { + best = &*it; + bestScore = score; + } + } + return best; + } + + struct CaseInsensitiveLess { + bool operator()(const std::string& a, const std::string& b) const noexcept { + return less_ci(a, b); + } + }; + + using AliasMap = std::map; + + AliasMap::const_iterator findAlias(std::string_view family) const { + return m_aliases.find(std::string(family)); + } + + bool addAlias(std::string_view existingFamily, std::string_view alias) override { + if (m_libraryOwner == nullptr || m_libraryOwner->library == nullptr) { + return false; + } + + struct FontFileRecordComparator { + bool operator()(const FontFileRecord& record, std::string_view name) const noexcept { + return less_ci(record.familyName, name); + } + + bool operator()(std::string_view name, const FontFileRecord& record) const noexcept { + return less_ci(name, record.familyName); + } + }; + + const auto [begin, end] = std::equal_range(m_fontFiles.begin(), m_fontFiles.end(), existingFamily, + FontFileRecordComparator{}); + if (begin == end) { + return false; + } + m_aliases.insert_or_assign(std::string(alias), std::string(begin->familyName)); + return true; + } + + std::optional normalizeVariations(const FontFileRecord& record, + std::span requested) const { + if (record.variationAxes.empty()) { + return requested.empty() ? std::optional(NormalizedVariations{}) + : std::nullopt; + } + + NormalizedVariations normalized; + for (const FontFileRecord::VariationAxis& axis : record.variationAxes) { + normalized.push_back({ axis.tag, axis.defaultValue }); + } + + inline_vector seen; + bool valid = true; + for (const FontVariation& variation : requested) { + const FT_ULong tag = variation.axisTag; + if (std::find(seen.begin(), seen.end(), tag) != seen.end()) { + valid = false; + break; + } + seen.push_back(tag); + const auto* axesBegin = record.variationAxes.data(); + const auto* axesEnd = record.variationAxes.end(); + const auto* axis = + std::find_if(axesBegin, axesEnd, [tag](const FontFileRecord::VariationAxis& candidate) { + return candidate.tag == tag; + }); + if (axis == axesEnd) { + valid = false; + break; + } + const double scaled = static_cast(toFloat(variation.value)) * 65536.0; + if (!std::isfinite(scaled) || scaled < axis->minimum || scaled > axis->maximum) { + valid = false; + break; + } + normalized[static_cast(axis - axesBegin)].value = + static_cast(std::llround(scaled)); + } + + std::sort(normalized.begin(), normalized.end()); + return valid ? std::optional(normalized) : std::nullopt; + } + + std::shared_ptr getOrCreateFace(const FaceKey& key, const FontFileRecord& record) const { + if (auto found = m_faces.find(key); found != m_faces.end()) { + if (std::shared_ptr face = found->second.lock()) { + return face; + } + m_faces.erase(found); + } + + auto entry = std::make_shared(); + entry->libraryOwner = m_libraryOwner; + ++m_ftNewFaceCalls; + const FT_Error openError = + record.data != nullptr + ? FT_New_Memory_Face(m_libraryOwner->library, reinterpret_cast(record.data), + static_cast(record.dataSize), key.faceIndex, &entry->ftFace) + : FT_New_Face(m_libraryOwner->library, record.path.string().c_str(), key.faceIndex, + &entry->ftFace); + if (openError != 0) { + return {}; + } + FT_Matrix matrix{ static_cast(0x10000 / kHorizontalOversampling), 0, 0, 0x10000 }; + FT_Set_Transform(entry->ftFace, &matrix, nullptr); + if (!key.variations.empty()) { + FT_MM_Var* mm = nullptr; + if (FT_Get_MM_Var(entry->ftFace, &mm) != 0 || mm == nullptr) { + return {}; + } + std::vector coordinates(mm->num_axis); + bool valid = true; + for (FT_UInt i = 0; i < mm->num_axis; ++i) { + auto coordinate = std::find_if(key.variations.begin(), key.variations.end(), + [&](const VariationCoordinate& item) { + return item.tag == mm->axis[i].tag; + }); + if (coordinate == key.variations.end()) { + valid = false; + break; + } + coordinates[i] = coordinate->value; + } + FT_Done_MM_Var(m_libraryOwner->library, mm); + if (!valid || + FT_Set_Var_Design_Coordinates(entry->ftFace, static_cast(coordinates.size()), + coordinates.data()) != 0) { + return {}; + } + } + + m_faces.emplace(key, entry); + return entry; + } + + FontHandle createInstance(std::shared_ptr face, const SizeKey& key, + bool asciiRangePresent) const { + auto instance = std::make_shared(); + instance->face = std::move(face); + instance->pixels = key.pixels; + instance->loadFlags = key.loadFlags; + instance->asciiRangePresent = asciiRangePresent; + instance->instanceId = detail::nextFontInstanceId(); + const FT_Error newError = FT_New_Size(instance->face->ftFace, &instance->ftSize); + const FT_Error activateError = newError == 0 ? FT_Activate_Size(instance->ftSize) : newError; + if (newError != 0 || activateError != 0) { + return {}; + } + instance->face->activeSize = instance->ftSize; + FT_Size_RequestRec request{ FT_SIZE_REQUEST_TYPE_NOMINAL, + static_cast(key.pixels * kHorizontalOversampling), key.pixels, 0, + 0 }; + const FT_Error requestError = FT_Request_Size(instance->face->ftFace, &request); + if (requestError != 0) { + instance->face->activeSize = nullptr; + return {}; + } + instance->hbFont = hb_ft_font_create_referenced(instance->face->ftFace); + if (instance->hbFont == nullptr) { + instance->face->activeSize = nullptr; + return {}; + } + hb_ft_font_set_load_flags(instance->hbFont, key.loadFlags); + ++m_sizeInitializations; + return instance; + } + + bool belongsToThisDatabase(const FontHandle& handle) const noexcept { + return handle != nullptr && handle->face != nullptr && handle->face->libraryOwner == m_libraryOwner; + } + + void remember(const FontHandle& handle) const { + m_recentInstances[m_nextRecentInstance] = handle; + m_nextRecentInstance = (m_nextRecentInstance + 1) % m_recentInstances.size(); + } + +public: + void scanDirectory(const std::filesystem::path& directory) override { + std::error_code ec; + if (!std::filesystem::is_directory(directory, ec)) { + return; + } + + for (const auto& entry : std::filesystem::directory_iterator(directory, ec)) { + if (!entry.is_regular_file()) { + continue; + } + const std::string ext = entry.path().extension().string(); + if (!equal_ci(ext, ".ttf") && !equal_ci(ext, ".otf") && !equal_ci(ext, ".ttc")) { + continue; + } + + ec.clear(); + const std::filesystem::path canonical = std::filesystem::weakly_canonical(entry.path(), ec); + const std::filesystem::path path = ec ? entry.path() : canonical; + FT_Face first = nullptr; + ++m_ftNewFaceCalls; + if (FT_New_Face(m_libraryOwner->library, path.string().c_str(), 0, &first) != 0) { + continue; + } + const FT_Long faceCount = first->num_faces; + FT_Done_Face(first); + ++m_ftDoneFaceCalls; + + for (FT_Long faceIndex = 0; faceIndex < faceCount; ++faceIndex) { + FT_Face face = nullptr; + ++m_ftNewFaceCalls; + if (FT_New_Face(m_libraryOwner->library, path.string().c_str(), faceIndex, &face) != 0) { + continue; + } + FontFileRecord record; + record.id = ++m_uniqueFontFileId; + record.path = path; + record.faceIndex = faceIndex; + if (face->family_name != nullptr) { + record.familyName = face->family_name; + } + record.italic = (face->style_flags & FT_STYLE_FLAG_ITALIC) != 0; + TT_OS2* os2 = static_cast(FT_Get_Sfnt_Table(face, FT_SFNT_OS2)); + record.weight = os2 != nullptr && os2->version != 0xFFFF + ? static_cast(os2->usWeightClass) + : ((face->style_flags & FT_STYLE_FLAG_BOLD) != 0 + ? static_cast(FontWeight::Bold) + : static_cast(FontWeight::Regular)); + FT_MM_Var* mm = nullptr; + if (FT_Get_MM_Var(face, &mm) == 0 && mm != nullptr) { + const FT_UInt axisCount = std::min(mm->num_axis, static_cast(kMaxVariationAxes)); + for (FT_UInt i = 0; i < axisCount; ++i) { + record.variationAxes.push_back( + { mm->axis[i].tag, mm->axis[i].minimum, mm->axis[i].def, mm->axis[i].maximum }); + } + FT_Done_MM_Var(m_libraryOwner->library, mm); + } + record.asciiFastShapingSafe = isAsciiFastShapingSafe(face); + record.asciiRangePresent = fontHasGlyphs(face, kAsciiRange); + FT_Done_Face(face); + ++m_ftDoneFaceCalls; + m_fontFiles.push_back(std::move(record)); + } + } + + std::sort(m_fontFiles.begin(), m_fontFiles.end(), + [](const FontFileRecord& a, const FontFileRecord& b) { + return less_ci(a.familyName, b.familyName); + }); + } + + static constexpr size_t kRecentInstanceCount = 8; + std::shared_ptr m_libraryOwner; + std::vector m_fontFiles; + /// Alias name -> canonical family name (as registered in m_fontFiles). + AliasMap m_aliases; + mutable std::map> m_faces; + mutable std::map, std::weak_ptr> m_instanceLookup; + mutable std::array m_recentInstances{}; + mutable size_t m_nextRecentInstance{}; + mutable size_t m_sizeInitializations{}; + mutable size_t m_ftNewFaceCalls{}; + mutable size_t m_ftDoneFaceCalls{}; + mutable uint64_t m_uniqueFontFileId{}; +}; + +std::shared_ptr getDefaultFontDatabase() { + static const auto database = [] { + auto db = std::make_shared(); + db->scanDirectory(detail::findFontsDirectory()); + return db; + }(); + return database; +} + +std::shared_ptr createFontDatabase() { + return std::make_shared(); +} + +std::shared_ptr createFontDatabase(std::shared_ptr owner, void* library) { + return std::make_shared(std::move(owner), library); +} + +} // namespace Brisk::TextEngine diff --git a/src/graphics/TextEngine/Layout.cpp b/src/graphics/TextEngine/Layout.cpp new file mode 100644 index 00000000..e65b27af --- /dev/null +++ b/src/graphics/TextEngine/Layout.cpp @@ -0,0 +1,1901 @@ +#include + +#include + +#include +#include +#include +#include +#include FT_FREETYPE_H +#include FT_ADVANCES_H +#include FT_TRUETYPE_TABLES_H +#include FT_OUTLINE_H + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Brisk::TextEngine { + +namespace { +// Monotonic 64-bit cookie identifying each PreparedDocument, copied into DocumentLayout. +uint64_t nextCookie() { + static std::atomic counter{ 0 }; + const uint64_t sequence = counter.fetch_add(1, std::memory_order_relaxed); + // Mix in a per-process base from the high-resolution clock so cookies are not trivially + // predictable across processes. + static const uint64_t base = [] { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return static_cast(now.count()); + }(); + return base ^ (sequence * 0x9E3779B97F4A7C15ull); +} +} // namespace + +bool shouldSplitRunAt(char32_t cp) { + // Defensive: invalid/surrogate codepoints never get shaped. + if (cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) { + return true; + } + + utf8proc_category_t cat = utf8proc_category(static_cast(cp)); + + switch (cat) { + case UTF8PROC_CATEGORY_CC: // control chars: TAB, VT, FF, etc. + case UTF8PROC_CATEGORY_ZL: // U+2028 LINE SEPARATOR + case UTF8PROC_CATEGORY_ZP: // U+2029 (shouldn't appear post-paragraph-split, but be safe) + return true; + case UTF8PROC_CATEGORY_CF: // format controls: ZWJ, ZWNJ, LRM, + // RLM, embedding controls, SHY, etc. + default: + return false; // keep in the HarfBuzz buffer + } +} + +namespace { + +ParagraphSeparatorKind paragraphSeparatorKind(std::u32string_view text, CodepointIndex begin, + CodepointIndex end) { + if (end == begin) { + return ParagraphSeparatorKind::None; + } + + switch (text[end - 1]) { + case U'\n': + if (end - begin >= 2 && text[end - 2] == U'\r') { + return ParagraphSeparatorKind::CarriageReturnLineFeed; + } else { + return ParagraphSeparatorKind::LineFeed; + } + case U'\r': + return ParagraphSeparatorKind::CarriageReturn; + case U'\u001C': + return ParagraphSeparatorKind::InformationSeparator4; + case U'\u001D': + return ParagraphSeparatorKind::InformationSeparator3; + case U'\u001E': + return ParagraphSeparatorKind::InformationSeparator2; + case U'\u0085': + return ParagraphSeparatorKind::NextLine; + case U'\u2029': + return ParagraphSeparatorKind::ParagraphSeparator; + default: + return ParagraphSeparatorKind::None; + } +} + +constexpr std::array pairedCharacters{ + U'(', U')', U'<', U'>', U'[', U']', U'{', U'}', U'\u00AB', + U'\u00BB', U'\u2018', U'\u2019', U'\u201C', U'\u201D', U'\u2039', U'\u203A', U'\u3008', U'\u3009', + U'\u300A', U'\u300B', U'\u300C', U'\u300D', U'\u300E', U'\u300F', U'\u3010', U'\u3011', U'\u3014', + U'\u3015', U'\u3016', U'\u3017', U'\u3018', U'\u3019', U'\u301A', U'\u301B', +}; + +int pairedCharacterIndex(char32_t codepoint) { + const auto it = std::ranges::lower_bound(pairedCharacters, codepoint); + return it != pairedCharacters.end() && *it == codepoint ? static_cast(it - pairedCharacters.begin()) + : -1; +} + +bool isExplicitScript(hb_script_t script) { + return script != HB_SCRIPT_COMMON && script != HB_SCRIPT_INHERITED && script != HB_SCRIPT_UNKNOWN && + script != HB_SCRIPT_INVALID; +} + +hb_script_t graphemeScript(std::u32string_view text, CodepointRange range, + hb_unicode_funcs_t* unicodeFunctions) { + hb_script_t result = HB_SCRIPT_INVALID; + for (CodepointIndex i = range.min; i < range.max; ++i) { + const hb_script_t script = hb_unicode_script(unicodeFunctions, text[i]); + if (isExplicitScript(script)) { + return script; + } + if (result == HB_SCRIPT_INVALID || result == HB_SCRIPT_INHERITED) { + result = script; + } + } + return result == HB_SCRIPT_INVALID ? HB_SCRIPT_COMMON : result; +} + +bool isAutomaticScript(ScriptTag script) { + return script == 0; +} + +bool isCursiveScript(ScriptTag script) { + switch (static_cast(script)) { + case HB_SCRIPT_ARABIC: + case HB_SCRIPT_SYRIAC: + case HB_SCRIPT_THAANA: + case HB_SCRIPT_NKO: + case HB_SCRIPT_SAMARITAN: + case HB_SCRIPT_MANDAIC: + case HB_SCRIPT_MONGOLIAN: + case HB_SCRIPT_PHAGS_PA: + case HB_SCRIPT_ADLAM: + return true; + default: + return false; + } +} + +} // namespace + +uint32_t paragraphSeparatorKindCodepoints(ParagraphSeparatorKind kind) { + switch (kind) { + case ParagraphSeparatorKind::None: + default: + return 0; + case ParagraphSeparatorKind::LineFeed: + [[fallthrough]]; + case ParagraphSeparatorKind::CarriageReturn: + [[fallthrough]]; + case ParagraphSeparatorKind::InformationSeparator4: + [[fallthrough]]; + case ParagraphSeparatorKind::InformationSeparator3: + [[fallthrough]]; + case ParagraphSeparatorKind::InformationSeparator2: + [[fallthrough]]; + case ParagraphSeparatorKind::NextLine: + [[fallthrough]]; + case ParagraphSeparatorKind::ParagraphSeparator: + return 1; + case ParagraphSeparatorKind::CarriageReturnLineFeed: + return 2; + } +} + +void segmentParagraphs(const DocumentSource& document, CodepointRange range, + function_ref onParagraph) { + const std::u32string_view text = document.text; + range = document.clamp(range); + if (range.empty()) { + onParagraph(range, ParagraphSeparatorKind::None); + return; + } + + ParagraphBreakIterator iterator; + iterator.setText(text.substr(range.min, range.distance())); + + size_t count = 0; + ParagraphSeparatorKind lastKind = ParagraphSeparatorKind::None; + + CodepointIndex localBegin = iterator.nextBreak(); + for (CodepointIndex localEnd = iterator.nextBreak(); localEnd != UINT32_MAX; + localEnd = iterator.nextBreak()) { + const CodepointIndex begin = range.min + localBegin; + const CodepointIndex end = range.min + localEnd; + lastKind = paragraphSeparatorKind(text, begin, end); + onParagraph(CodepointRange{ begin, end }, lastKind); + count++; + localBegin = localEnd; + } + + if (count == 0 || lastKind != ParagraphSeparatorKind::None) { + onParagraph(CodepointRange{ range.max, range.max }, ParagraphSeparatorKind::None); + } +} + +Direction resolveBidi(const DocumentSource& document, CodepointRange paragraphRange, + BaseDirection baseDirection, function_ref onRun) { + const std::u32string_view text = document.text; + paragraphRange = document.clamp(paragraphRange); + BiDiIterator iterator; + iterator.setText(text.substr(paragraphRange.min, paragraphRange.distance()), baseDirection); + + for (;;) { + const auto& run = iterator.nextRun(); + if (run.range.empty()) { + break; + } + onRun(CodepointRange{ paragraphRange.min + run.range.min, paragraphRange.min + run.range.max }, + run.level); + } + + return static_cast(iterator.paragraphLevel()); +} + +void detectScripts(const DocumentSource& document, CodepointRange paragraphRange, std::span out) { + const std::u32string_view text = document.text; + assert(paragraphRange.max <= text.size()); + assert(document.graphemes.codepointCount() == text.size()); + + const GraphemeRange paragraphGraphemeRange = document.graphemes.toGraphemeRange(paragraphRange); + const size_t graphemeCount = paragraphGraphemeRange.distance(); + + assert(out.size() == graphemeCount); + if (graphemeCount == 0) { + return; + } + + hb_unicode_funcs_t* unicodeFunctions = hb_unicode_funcs_get_default(); + + size_t i = 0; + document.graphemes.iterate(paragraphGraphemeRange, [&](CodepointRange range) { + out[i++] = static_cast(graphemeScript(text, range, unicodeFunctions)); + }); + + struct PairedEntry { + int pairIndex; + hb_script_t script; + }; + + hb_script_t lastScript = HB_SCRIPT_INVALID; + size_t firstUnresolved = 0; + + // Real-world text rarely nests paired punctuation more than a few levels deep. Keep a + // bounded stack here so pathological input cannot turn script detection into an allocation + // hotspot. If the bound is reached, discard the incomplete pairing context and continue + // using the surrounding script rather than allowing inline_vector::push_back to throw. + constexpr size_t kMaximumPairedCharacterNesting = 128; + inline_vector pairedStack; + + for (size_t i = 0; i < graphemeCount; ++i) { + hb_script_t gScript = static_cast(out[i]); + const CodepointRange codepointRange = document.graphemes.codepointRangeForGrapheme( + paragraphGraphemeRange.min + static_cast(i)); + const int pairIndex = + codepointRange.distance() == 1 ? pairedCharacterIndex(text[codepointRange.min]) : -1; + + if (gScript == HB_SCRIPT_COMMON && lastScript != HB_SCRIPT_INVALID) { + if (pairIndex >= 0 && (pairIndex & 1) == 0) { + gScript = lastScript; + if (pairedStack.size() < pairedStack.capacity()) { + pairedStack.push_back(PairedEntry{ pairIndex, gScript }); + } else { + // There is no reliable way to match a close after the stack overflows. + // Dropping the context is preferable to throwing or misapplying a stale + // nested pairing to the rest of the paragraph. + pairedStack = {}; + } + } else if (pairIndex >= 0) { + while (!pairedStack.empty() && pairedStack.back().pairIndex != (pairIndex & ~1)) { + pairedStack.pop_back(); + } + gScript = pairedStack.empty() ? lastScript : pairedStack.back().script; + lastScript = gScript; + if (!pairedStack.empty()) { + pairedStack.pop_back(); + } + } else { + gScript = lastScript; + } + } else if (gScript == HB_SCRIPT_INHERITED && lastScript != HB_SCRIPT_INVALID) { + gScript = lastScript; + } else if (isExplicitScript(gScript)) { + for (size_t j = firstUnresolved; j < i; ++j) { + if (out[j] == static_cast(HB_SCRIPT_COMMON) || + out[j] == static_cast(HB_SCRIPT_INHERITED)) { + out[j] = static_cast(gScript); + } + } + lastScript = gScript; + firstUnresolved = i + 1; + } + out[i] = static_cast(gScript); + } + + for (size_t i = graphemeCount; i-- > 0;) { + if (out[i] != static_cast(HB_SCRIPT_COMMON) && + out[i] != static_cast(HB_SCRIPT_INHERITED)) { + break; + } + if (i + 1 < graphemeCount) { + out[i] = out[i + 1]; + } + } +} + +void itemizeScripts(const DocumentSource& document, CodepointRange bidiRange, BiDiLevel level, + ScriptTag script, std::span runScripts, + function_ref onRun) { + const std::u32string_view text = document.text; + bidiRange = document.clamp(bidiRange); + if (bidiRange.empty()) { + return; + } + + if (!isAutomaticScript(script)) { + onRun(bidiRange, ScriptRun{ + .level = level, + .script = script, + }); + return; + } + + const GraphemeRange bidiGraphemeRange = document.graphemes.toGraphemeRange(bidiRange); + const size_t graphemeCount = bidiGraphemeRange.distance(); + if (graphemeCount == 0) { + return; + } + + assert(runScripts.size() >= graphemeCount); + if (runScripts.empty()) { + return; + } + + CodepointIndex runStart = document.graphemes.codepointRangeForGrapheme(bidiGraphemeRange.min).min; + uint32_t currentScript = runScripts[0]; + + for (size_t i = 1; i < graphemeCount; ++i) { + const uint32_t gScript = runScripts[i]; + if (gScript != currentScript) { + const CodepointIndex gMin = + document.graphemes + .codepointRangeForGrapheme(bidiGraphemeRange.min + static_cast(i)) + .min; + onRun({ runStart, gMin }, ScriptRun{ + .level = level, + .script = currentScript, + }); + runStart = gMin; + currentScript = gScript; + } + } + + onRun({ runStart, document.graphemes.codepointRangeForGrapheme(bidiGraphemeRange.max - 1).max }, + { + .level = level, + .script = currentScript, + }); +} + +namespace { + +bool isDefaultIgnorableCodepoint(char32_t codepoint) { + return codepoint == 0x00AD || // SOFT HYPHEN + codepoint == 0x034F || // COMBINING GRAPHEME JOINER + codepoint == 0x061C || // ARABIC LETTER MARK + (codepoint >= 0x115F && codepoint <= 0x1160) || (codepoint >= 0x17B4 && codepoint <= 0x17B5) || + (codepoint >= 0x180B && codepoint <= 0x180F) || (codepoint >= 0x200B && codepoint <= 0x200F) || + (codepoint >= 0x202A && codepoint <= 0x202E) || (codepoint >= 0x2060 && codepoint <= 0x206F) || + (codepoint >= 0xFE00 && codepoint <= 0xFE0F) || codepoint == 0xFEFF || + (codepoint >= 0xFFF0 && codepoint <= 0xFFF8) || (codepoint >= 0xE0000 && codepoint <= 0xE0FFF); +} + +bool fontSupportsGrapheme(const FontDatabase* fontDatabase, const FontHandle& fontHandle, + std::u32string_view text, CodepointRange range) { + if (fontDatabase == nullptr || !fontHandle) { + return false; + } + const ActiveFont font = fontDatabase->activate(fontHandle); + const FT_Face face = static_cast(font.ftFace); + if (face == nullptr) { + return false; + } + for (CodepointIndex i = range.min; i < range.max; ++i) { + const char32_t codepoint = text[i]; + // The registration-time ASCII probe already verified this complete + // range. Avoid repeating cmap lookups for the overwhelmingly common + // ASCII case; non-ASCII and partial/unknown ranges still use the + // exact per-codepoint lookup below. + const bool knownAscii = codepoint >= U' ' && codepoint <= U'~' && font.asciiRangePresent; + if (!isDefaultIgnorableCodepoint(codepoint) && !knownAscii && + FT_Get_Char_Index(face, static_cast(codepoint)) == 0) { + return false; + } + } + return true; +} + +FontHandle chooseFontForGrapheme(std::u32string_view text, CodepointRange gRange, + std::span fontHandles, const FontDatabase* fontDatabase) { + if (fontDatabase == nullptr) { + return {}; + } + + for (const FontHandle& handle : fontHandles) { + if (handle && fontSupportsGrapheme(fontDatabase, handle, text, gRange)) { + return handle; + } + } + return {}; +} + +} // namespace + +VerticalMetrics getVerticalMetrics(const FontDatabase* fontDatabase, const FontHandle& fontHandle) { + if (fontDatabase == nullptr || !fontHandle) { + return VerticalMetrics{ + .ascent = kZero, + .descent = kZero, + .lineGap = kZero, + }; + } + + const ActiveFont font = fontDatabase->activate(fontHandle); + hb_font_t* hbFont = static_cast(font.hbFont); + + hb_position_t ascender, descender, line_gap; + hb_ot_metrics_get_position_with_fallback(hbFont, HB_OT_METRICS_TAG_HORIZONTAL_ASCENDER, &ascender); + hb_ot_metrics_get_position_with_fallback(hbFont, HB_OT_METRICS_TAG_HORIZONTAL_DESCENDER, &descender); + hb_ot_metrics_get_position_with_fallback(hbFont, HB_OT_METRICS_TAG_HORIZONTAL_LINE_GAP, &line_gap); + + return VerticalMetrics{ + .ascent = from26Dot6(ascender), + .descent = from26Dot6(descender), + .lineGap = from26Dot6(line_gap), + }; +} + +ExtendedMetrics getExtendedMetrics(const FontDatabase* fontDatabase, const FontHandle& fontHandle) { + if (fontDatabase == nullptr || !fontHandle) { + return {}; + } + + const ActiveFont font = fontDatabase->activate(fontHandle); + const FT_Face face = static_cast(font.ftFace); + if (face == nullptr || face->size == nullptr) { + return {}; + } + + const FT_Fixed yScale = face->size->metrics.y_scale; + const auto scaleFontUnits = [yScale](FT_Pos value) { + return from26Dot6(FT_MulFix(value, yScale)); + }; + + ExtendedMetrics result{ + .underlinePosition = scaleFontUnits(-face->underline_position), + .lineThickness = scaleFontUnits(face->underline_thickness), + }; + + const FT_UInt spaceGlyph = FT_Get_Char_Index(face, static_cast(' ')); + FT_Fixed spaceAdvance = 0; + if (spaceGlyph != 0 && + FT_Get_Advance(face, spaceGlyph, FT_LOAD_DEFAULT | FT_LOAD_NO_BITMAP, &spaceAdvance) == 0) { + result.spaceAdvanceX = fromFloat(static_cast(spaceAdvance) * (1.0f / 65536.0f)); + } + + if (const auto* os2 = static_cast(FT_Get_Sfnt_Table(face, ft_sfnt_os2)); os2 != nullptr) { + result.xHeight = scaleFontUnits(os2->sxHeight); + result.capitalHeight = scaleFontUnits(os2->sCapHeight); + } + + return result; +} + +void resolveFonts( + const DocumentSource& document, CodepointRange scriptRange, const ScriptRun& run, + const FontDatabase* fontDatabase, std::span fonts, BoundaryTable fontBoundaries, + function_ref onShapeableRun, + function_ref onControlRun) { + const std::u32string_view text = document.text; + scriptRange = document.clamp(scriptRange); + if (scriptRange.empty()) { + return; + } + + assert(fontBoundaries.correct()); + assert(fontBoundaries.count() == fonts.size()); + + std::vector> fontHandlesPerRun(fonts.size()); + + auto getOrResolveFontHandle = [&](FontRunIndex fontRunIdx, std::u32string_view gText, + CodepointRange gRange) -> FontHandle { + if (fontDatabase == nullptr || fontRunIdx >= fonts.size()) { + return {}; + } + const FontDef& fontDef = *fonts[fontRunIdx]; + std::vector& fontHandles = fontHandlesPerRun[fontRunIdx]; + + if (fontHandles.empty() && !fontDef.familyNames.empty()) { + FontHandle handle = + fontDatabase->resolveFont(fontDef.familyNames[0], fontDef.style, fontDef.weight, + fontDef.fontSize, fontDef.variations, fontDef.hinting); + fontHandles.push_back(std::move(handle)); + } + + size_t triedCount = 0; + while (triedCount < fontHandles.size()) { + std::span handlesToTry{ fontHandles.data() + triedCount, + fontHandles.size() - triedCount }; + FontHandle chosen = chooseFontForGrapheme(gText, gRange, handlesToTry, fontDatabase); + if (chosen) { + return chosen; + } + triedCount = fontHandles.size(); + + if (fontHandles.size() < fontDef.familyNames.size()) { + const size_t nextFamilyIdx = fontHandles.size(); + FontHandle handle = fontDatabase->resolveFont(fontDef.familyNames[nextFamilyIdx], + fontDef.style, fontDef.weight, fontDef.fontSize, + fontDef.variations, fontDef.hinting); + fontHandles.push_back(std::move(handle)); + } + } + + // Return the first valid font handle as fallback if none supported the grapheme + for (const FontHandle& handle : fontHandles) { + if (handle) { + return handle; + } + } + return {}; + }; + + CodepointRange pendingRange{ scriptRange.min, scriptRange.min }; + FontHandle pendingFontHandle{}; + FontRunIndex pendingFontRunIdx = 0; + bool hasPendingRun = false; + + auto flushPending = [&]() { + if (hasPendingRun) { + onShapeableRun(pendingRange, ResolvedFont{ + .fontHandle = pendingFontHandle, + .fontRunIndex = pendingFontRunIdx, + }); + hasPendingRun = false; + } + }; + + const GraphemeRange scriptGraphemeRange = document.graphemes.toGraphemeRange(scriptRange); + document.graphemes.iterate(scriptGraphemeRange, [&](CodepointRange gRange) { + // Check if this grapheme cluster is a single control codepoint + if (gRange.distance() == 1 && shouldSplitRunAt(text[gRange.min])) { + flushPending(); + const FontRunIndex fontRunIdx = fontBoundaries.indexOf(gRange.min); + const FontHandle fontHandle = getOrResolveFontHandle(fontRunIdx, text, gRange); + onControlRun(gRange, text[gRange.min], + ResolvedFont{ + .fontHandle = fontHandle, + .fontRunIndex = fontRunIdx, + }); + return; + } + + const FontRunIndex fontRunIdx = fontBoundaries.indexOf(gRange.min); + const FontHandle chosenFont = getOrResolveFontHandle(fontRunIdx, text, gRange); + + if (hasPendingRun) { + if (chosenFont == pendingFontHandle && fontRunIdx == pendingFontRunIdx) { + // Extend the current shapeable run + pendingRange.max = gRange.max; + } else { + flushPending(); + pendingRange = gRange; + pendingFontHandle = chosenFont; + pendingFontRunIdx = fontRunIdx; + hasPendingRun = true; + } + } else { + pendingRange = gRange; + pendingFontHandle = chosenFont; + pendingFontRunIdx = fontRunIdx; + hasPendingRun = true; + } + }); + + flushPending(); +} + +namespace { + +hb_language_t toHbLanguage(std::string_view language) { + if (language.empty()) { + return hb_language_get_default(); + } + return hb_language_from_string(language.data(), static_cast(language.size())); +} + +hb_direction_t levelToHbDirection(BiDiLevel level) { + return directionFromLevel(level) == Direction::RightToLeft ? HB_DIRECTION_RTL : HB_DIRECTION_LTR; +} + +void transformFreetypeVector(int32_t* x, int32_t* y, const FT_Matrix& matrix) { + FT_Vector vector{ .x = *x, .y = *y }; + FT_Vector_Transform(&vector, &matrix); + *x = static_cast(vector.x); + *y = static_cast(vector.y); +} + +struct hb_buffer_deleter { + void operator()(hb_buffer_t* buffer) const noexcept { + hb_buffer_destroy(buffer); + } +}; + +} // namespace + +void shapeRun(const DocumentSource& document, CodepointRange textContextRange, CodepointRange runRange, + const FontDatabase* fontDatabase, const ResolvedFont& run, BiDiLevel level, ScriptTag script, + const FontDef& fontDef, std::string_view language, + function_ref onGlyph) { + const std::u32string_view text = document.text; + assert(runRange.min >= textContextRange.min && runRange.max <= textContextRange.max); + assert(textContextRange.max <= text.size()); + if (runRange.empty()) { + // No glyphs to shape + return; + } + + const GraphemeRange runGraphemeRange = document.graphemes.toGraphemeRange(runRange); + const auto graphemeCount = static_cast(runGraphemeRange.distance()); + + if (!run.fontHandle) { + // Unrenderable run: produce a missing/replacement glyph (glyphId 0) per grapheme + for (GraphemeIndex gIdx = 0; gIdx < graphemeCount; ++gIdx) { + onGlyph( + Glyph{ + .glyphId = 0, + .graphemeIndex = gIdx, + .xAdvance = kZero, + .xOffset = kZero, + .yOffset = kZero, + }, + false); + } + + return; + } + + const ActiveFont font = fontDatabase == nullptr ? ActiveFont{} : fontDatabase->activate(run.fontHandle); + if (font.ftFace == nullptr || font.hbFont == nullptr) { + return; + } + const FT_Face ftFace = static_cast(font.ftFace); + hb_font_t* hbFont = static_cast(font.hbFont); + + // TODO: Create a HarfBuzz buffer pool + std::unique_ptr buffer(hb_buffer_create()); + assert(buffer.get() != nullptr); + + hb_buffer_add_utf32(buffer.get(), reinterpret_cast(text.data()), + static_cast(text.size()), runRange.min, runRange.distance()); + hb_buffer_set_script(buffer.get(), hb_script_from_iso15924_tag(script)); + // TODO: Cache the HarfBuzz language object per language string + hb_buffer_set_language(buffer.get(), toHbLanguage(language)); + hb_buffer_set_direction(buffer.get(), levelToHbDirection(level)); + hb_buffer_flags_t flags = HB_BUFFER_FLAG_REMOVE_DEFAULT_IGNORABLES; + if (runRange.min == textContextRange.min) { + flags = static_cast(flags | HB_BUFFER_FLAG_BOT); + } + if (runRange.max == textContextRange.max) { + flags = static_cast(flags | HB_BUFFER_FLAG_EOT); + } + hb_buffer_set_flags(buffer.get(), flags); + + inline_vector hbFeatures; + auto features = + fontDef.features.subspan(0, std::min(fontDef.features.size(), hbFeatures.capacity())); + + for (const auto& feat : features) { + hbFeatures.push_back(hb_feature_t{ + .tag = feat.feature, + .value = feat.enabled ? 1u : 0u, + .start = HB_FEATURE_GLOBAL_START, + .end = HB_FEATURE_GLOBAL_END, + }); + } + + const bool shaped = hb_shape_full(hbFont, buffer.get(), hbFeatures.empty() ? nullptr : hbFeatures.data(), + static_cast(hbFeatures.size()), nullptr); + assert(shaped); + + unsigned int glyphCount = 0; + const hb_glyph_info_t* infos = hb_buffer_get_glyph_infos(buffer.get(), &glyphCount); + // hb_glyph_flags_t hb_glyph_info_get_glyph_flags (const hb_glyph_info_t *info); + + const hb_glyph_position_t* positions = hb_buffer_get_glyph_positions(buffer.get(), nullptr); + + for (unsigned int i = 0; i < glyphCount; ++i) { + int32_t xAdvance = positions[i].x_advance / kHorizontalOversampling; + assert(positions[i].y_advance == 0); + int32_t xOffset = positions[i].x_offset / kHorizontalOversampling; + int32_t yOffset = positions[i].y_offset; + + const GraphemeIndex graphemeIndex = + document.graphemes.toGrapheme(infos[i].cluster) - runGraphemeRange.min; + const LayoutUnit xAdv = from26Dot6(xAdvance); + + hb_glyph_flags_t flags = hb_glyph_info_get_glyph_flags(&infos[i]); + bool unsafeToBreak = static_cast(flags & HB_GLYPH_FLAG_UNSAFE_TO_BREAK); + + onGlyph( + Glyph{ + .glyphId = infos[i].codepoint, + .graphemeIndex = graphemeIndex, + .xAdvance = xAdv, + .xOffset = from26Dot6(xOffset), + .yOffset = from26Dot6(yOffset), + }, + unsafeToBreak); + } +} + +void breakOpportunities(std::u32string_view text, CodepointRange range, std::span out, + const char* language) { + assert(range.max <= text.size()); + assert(out.size() == range.distance()); + if (range.empty()) { + return; + } + set_linebreaks_utf32(reinterpret_cast(text.data() + range.min), + static_cast(range.distance()), language, + reinterpret_cast(out.data())); +} + +namespace { + +bool isTrimmableWhitespace(std::u32string_view text, CodepointRange range) { + if (range.empty()) { + return false; + } + for (CodepointIndex i = range.min; i < range.max; ++i) { + const char32_t cp = text[i]; + const utf8proc_category_t category = utf8proc_category(static_cast(cp)); + if (category != UTF8PROC_CATEGORY_ZS && cp != U'\t') { + return false; + } + } + return true; +} + +} // namespace + +void appendPreparedParagraph(const DocumentSource& document, CodepointRange paragraphRange, + std::shared_ptr fontDatabase, + std::span fonts, BoundaryTable fontBoundaries, + BaseDirection baseDirection, ScriptTag script, std::string_view language, + PreparedDocument& documentResult, PreparedDocument::Paragraph& paragraphResult) { + paragraphRange = document.clamp(paragraphRange); + const GraphemeRange graphemeRange = document.graphemes.toGraphemeRange(paragraphRange); + const GlyphIndex glyphBase = static_cast(documentResult.glyphs.size()); + const RunIndex runBase = static_cast(documentResult.glyphRuns.size()); + std::vector paragraphScripts(document.graphemes.toGraphemeRange(paragraphRange).distance()); + detectScripts(document, paragraphRange, paragraphScripts); + + for (GraphemeIndex localG = 0; localG < graphemeRange.distance(); ++localG) { + const GraphemeIndex globalG = graphemeRange.min + localG; + const CodepointRange codepointRange = document.graphemes.codepointRangeForGrapheme(globalG); + documentResult.graphemes[globalG].trimmableWhitespace = + isTrimmableWhitespace(document.text, codepointRange); + } + + const Direction resolvedBaseDirection = resolveBidi( + document, paragraphRange, baseDirection, [&](CodepointRange bidiRange, BiDiLevel bidiLevel) { + const CodepointIndex localBidiMin = bidiRange.min - paragraphRange.min; + const CodepointIndex localBidiMax = bidiRange.max - paragraphRange.min; + const GraphemeIndex localGraphemeMin = document.graphemes.toGrapheme(bidiRange.min); + const GraphemeIndex localGraphemeMax = document.graphemes.toGrapheme(bidiRange.max - 1) + 1; + const std::span bidiScripts{ paragraphScripts.data() + + (localGraphemeMin - graphemeRange.min), + localGraphemeMax - localGraphemeMin }; + + itemizeScripts( + document, bidiRange, bidiLevel, script, bidiScripts, + [&](CodepointRange scriptRange, const ScriptRun& scriptRun) { + resolveFonts( + document, scriptRange, scriptRun, fontDatabase.get(), fonts, fontBoundaries, + [&](CodepointRange fontRange, const ResolvedFont& resolvedFont) { + const GraphemeIndex graphemeMin = document.graphemes.toGrapheme(fontRange.min); + const GraphemeIndex graphemeMax = + document.graphemes.toGrapheme(fontRange.max - 1) + 1; + const GlyphIndex glyphMin = static_cast(documentResult.glyphs.size()); + const FontDef& fontDef = *fonts[resolvedFont.fontRunIndex]; + const bool applyLetterSpacing = !isCursiveScript(scriptRun.script); + const bool hasNextGrapheme = fontRange.max < paragraphRange.max; + LayoutUnit pen = kZero; + std::optional> pendingGlyph; + + auto emitPending = [&](bool endOfGrapheme, bool hasFollowingGrapheme) { + if (!pendingGlyph) { + return; + } + Glyph glyph = pendingGlyph->first; + const bool unsafeToBreak = pendingGlyph->second; + glyph.xOffset += pen; + const GraphemeIndex documentGrapheme = graphemeMin + glyph.graphemeIndex; + if (endOfGrapheme && + documentResult.graphemes[documentGrapheme].trimmableWhitespace && + !documentResult.graphemes[documentGrapheme].isTab) { + glyph.xAdvance += + bidiLevel % 2 == 0 ? fontDef.wordSpacing : -fontDef.wordSpacing; + } + if (endOfGrapheme && applyLetterSpacing && + (hasFollowingGrapheme || hasNextGrapheme)) { + glyph.xAdvance += + bidiLevel % 2 == 0 ? fontDef.letterSpacing : -fontDef.letterSpacing; + } + pen += glyph.xAdvance; + documentResult.graphemeAdvancePrefix[documentGrapheme + 1] += + glyph.xAdvance >= kZero ? glyph.xAdvance : -glyph.xAdvance; + if (unsafeToBreak && documentGrapheme > 0) { + documentResult.graphemes[documentGrapheme - 1].unsafeToBreakAfter = true; + } + documentResult.glyphs.push_back(glyph); + pendingGlyph.reset(); + }; + + shapeRun(document, paragraphRange, fontRange, fontDatabase.get(), resolvedFont, + bidiLevel, scriptRun.script, fontDef, language, + [&](const Glyph& sourceGlyph, bool unsafeToBreak) { + if (pendingGlyph && + pendingGlyph->first.graphemeIndex != sourceGlyph.graphemeIndex) { + emitPending(true, true); + } + if (pendingGlyph) { + emitPending(false, true); + } + pendingGlyph = std::pair{ sourceGlyph, unsafeToBreak }; + }); + emitPending(true, false); + if (pen < kZero) { + for (GlyphIndex i = glyphMin; i < documentResult.glyphs.size(); ++i) { + documentResult.glyphs[i].xOffset -= pen; + } + } + documentResult.glyphRuns.push_back(GlyphRun{ + .codepointRange = fontRange, + .graphemeRange = { graphemeMin, graphemeMax }, + .glyphRange = { glyphMin, + static_cast(documentResult.glyphs.size()) }, + .fontHandle = resolvedFont.fontHandle, + .fontRunIndex = resolvedFont.fontRunIndex, + .level = bidiLevel, + .script = scriptRun.script, + .verticalAlign = fonts[resolvedFont.fontRunIndex]->verticalAlign, + .metrics = getVerticalMetrics(fontDatabase.get(), resolvedFont.fontHandle), + }); + }, + [&](CodepointRange controlRange, char32_t codepoint, + const ResolvedFont& resolvedFont) { + const GraphemeIndex graphemeMin = document.graphemes.toGrapheme(controlRange.min); + documentResult.graphemes[graphemeMin].isTab = codepoint == U'\t'; + documentResult.glyphRuns.push_back(GlyphRun{ + .codepointRange = controlRange, + .graphemeRange = { graphemeMin, graphemeMin + 1 }, + .glyphRange = { static_cast(documentResult.glyphs.size()), + static_cast(documentResult.glyphs.size()) }, + .fontHandle = resolvedFont.fontHandle, + .fontRunIndex = resolvedFont.fontRunIndex, + .level = bidiLevel, + .script = scriptRun.script, + .metrics = getVerticalMetrics(fontDatabase.get(), resolvedFont.fontHandle), + }); + }); + }); + }); + + for (RunIndex runIndex = runBase; runIndex < documentResult.glyphRuns.size(); ++runIndex) { + GlyphRun& run = documentResult.glyphRuns[runIndex]; + const FontDef& fontDef = *fonts[run.fontRunIndex]; + if (fontDef.lineHeight > kZero) { + const LayoutUnit contentHeight = run.metrics.ascent - run.metrics.descent; + // An explicit CSS line-height is the complete line-box height. Keep + // negative leading when it is smaller than the font metrics; it is + // distributed equally around the content box below. + run.metrics.lineGap = fontDef.lineHeight - contentHeight; + } + } + + paragraphResult.paragraphRange = paragraphRange; + paragraphResult.baseDirection = resolvedBaseDirection; + paragraphResult.graphemeRange = graphemeRange; + paragraphResult.glyphRange = { glyphBase, static_cast(documentResult.glyphs.size()) }; + paragraphResult.glyphRunRange = { runBase, static_cast(documentResult.glyphRuns.size()) }; +} + +PreparedDocument prepareDocument(const DocumentSource& document, + std::shared_ptr fontDatabase, + std::span fonts, BoundaryTable fontBoundaries, + std::span baseDirections, ScriptTag script, + std::string_view language) { + struct SegmentedParagraph { + CodepointRange range; + ParagraphSeparatorKind separator; + }; + + std::vector segmented; + segmentParagraphs(document, entireText, [&](CodepointRange range, ParagraphSeparatorKind separator) { + const uint32_t separatorLength = paragraphSeparatorKindCodepoints(separator); + assert(range.distance() >= separatorLength); + segmented.push_back(SegmentedParagraph{ + .range = { range.min, range.max - separatorLength }, + .separator = separator, + }); + }); + + assert(baseDirections.empty() || baseDirections.size() == 1 || baseDirections.size() == segmented.size()); + assert(fontBoundaries.correct()); + assert(fontBoundaries.count() == fonts.size()); + + PreparedDocument result; + result.fontDatabase = std::move(fontDatabase); + result.cookie = nextCookie(); + result.graphemeMap = document.graphemes; + result.breakOpportunities.resize(document.codepointCount()); + result.graphemes.resize(document.graphemeCount()); + result.graphemeAdvancePrefix.resize(document.graphemeCount() + 1); + + result.paragraphs.reserve(segmented.size()); + for (size_t paragraphIndex = 0; paragraphIndex < segmented.size(); ++paragraphIndex) { + const SegmentedParagraph& segment = segmented[paragraphIndex]; + const BaseDirection direction = baseDirections.empty() + ? BaseDirection::DefaultLTR + : baseDirections[baseDirections.size() == 1 ? 0 : paragraphIndex]; + const GraphemeRange documentGraphemeRange = document.graphemes.toGraphemeRange(segment.range); + VerticalMetrics emptyLineMetrics{}; + if (segment.range.empty()) { + const uint32_t fontIndex = + fontBoundaries.total() == 0 + ? 0 + : fontBoundaries.indexOf(std::min(segment.range.min, fontBoundaries.total() - 1)); + emptyLineMetrics = getVerticalMetrics(result.fontDatabase.get(), + result.fontDatabase->resolveFont(*fonts[fontIndex])); + if (fonts[fontIndex]->lineHeight > kZero) { + const LayoutUnit contentHeight = emptyLineMetrics.ascent - emptyLineMetrics.descent; + emptyLineMetrics.lineGap = fonts[fontIndex]->lineHeight - contentHeight; + } + } + result.paragraphs.push_back(PreparedDocument::Paragraph{ + .paragraphRange = segment.range, + .paragraphSeparatorKind = segment.separator, + .graphemeRange = documentGraphemeRange, + .emptyLineMetrics = emptyLineMetrics, + }); + if (segment.separator != ParagraphSeparatorKind::None) { + const CodepointRange separatorRange{ + segment.range.max, segment.range.max + paragraphSeparatorKindCodepoints(segment.separator) + }; + const GraphemeRange separatorGraphemes = document.graphemes.toGraphemeRange(separatorRange); + for (GraphemeIndex grapheme = separatorGraphemes.min; grapheme < separatorGraphemes.max; + ++grapheme) { + result.graphemes[grapheme].isParagraphSeparator = true; + } + } + if (!segment.range.empty()) { + breakOpportunities(document.text, segment.range, + std::span{ result.breakOpportunities.data() + segment.range.min, + segment.range.distance() }, + language.empty() ? nullptr : std::string(language).c_str()); + } + appendPreparedParagraph(document, segment.range, result.fontDatabase, fonts, fontBoundaries, + direction, script, language, result, result.paragraphs.back()); + } + + LayoutUnit currentSum = kZero; + for (GraphemeIndex g = 0; g < result.graphemes.size(); ++g) { + currentSum += result.graphemeAdvancePrefix[g + 1]; + result.graphemeAdvancePrefix[g + 1] = currentSum; + } + return result; +} + +namespace { + +// Width of a line excluding whatever trailing-whitespace mass has accumulated at its end; +// clamped defensively so float rounding can never push it below zero. +LayoutUnit trimmedWidth(LayoutUnit width, LayoutUnit trailingWhitespaceMass) { + return width > trailingWhitespaceMass ? width - trailingWhitespaceMass : kZero; +} + +class DocumentGreedyLineBreaker { +public: + DocumentGreedyLineBreaker(const PreparedDocument& document, std::span alignments, + LayoutUnit maxLineWidth, TabStops tabStops, + std::span textIndents, bool allowBreakAnywhere); + + [[nodiscard]] DocumentLayout layoutPreparedDocument(); + +private: + struct LineRunInfo { + RunIndex preparedRunIndex; + BiDiLevel level; + VerticalMetrics metrics; + FontHandle fontHandle{}; + GlyphRange glyphRange{}; + GraphemeRange graphemeRange{}; + }; + + struct PendingSlice { + RunIndex runIndex; + GraphemeIndex runBase; + GraphemeRange rangeInParagraph; + }; + + struct LineRunSlice { + RunIndex runIndex; + GraphemeRange graphemeRange; + }; + + [[nodiscard]] LayoutUnit currentMaxLineWidth() const; + void addRun(const GlyphRun& run, RunIndex preparedRunIndex, bool finishParagraph); + void appendGrapheme(RunIndex runIndex, GraphemeIndex runBase, GraphemeIndex g, + const GraphemeFlags& grapheme); + [[nodiscard]] LayoutUnit resolveAdvance(GraphemeIndex g, const GraphemeFlags& grapheme) const; + void rebuildOpenLineFrom(GraphemeIndex begin); + [[nodiscard]] LayoutUnit sliceWidth(GraphemeRange range) const; + void commitLine(GraphemeIndex cutGrapheme, LineEndKind kind, LayoutUnit contentAdvance, + LayoutUnit trimmedAdvance); + + const PreparedDocument& m_preparedDocument; + std::vector m_runInfos; + std::span m_alignments; + LayoutUnit m_maxLineWidth; + std::span m_textIndents; + TabStops m_tabStops; + bool m_allowBreakAnywhere; + VerticalMetrics m_emptyLineMetrics{}; + + DocumentLayout m_output; + LayoutUnit m_cursor{ kZero }; + + GraphemeIndex m_lineStart{ 0 }; + GraphemeIndex m_nextGrapheme{ 0 }; + GraphemeIndex m_paragraphGraphemeBegin{ 0 }; + GraphemeIndex m_paragraphGraphemeEnd{ 0 }; + CodepointIndex m_paragraphCodepointBegin{ 0 }; + CodepointIndex m_paragraphCodepointEnd{ 0 }; + Direction m_baseDirection{ Direction::LeftToRight }; + TextAlignment m_alignment{ TextAlignment::Start }; + LayoutUnit m_textIndent{ kZero }; + bool m_paragraphFirstLine{ true }; + LayoutUnit m_lineWidth{ kZero }; + LayoutUnit m_trailingWhitespaceMass{ kZero }; + LayoutUnit m_postCandidateTrailingWhitespaceMass{ kZero }; + + bool m_hasCandidate{ false }; + GraphemeIndex m_candidateGrapheme{ 0 }; + LayoutUnit m_candidateWidth{ kZero }; + LayoutUnit m_candidateTrimmedWidth{ kZero }; + + std::vector m_resolvedAdvances; + + std::vector m_lineSlices; + std::vector m_outputSlices; + std::vector m_visualSlices; +}; + +} // namespace + +DocumentGreedyLineBreaker::DocumentGreedyLineBreaker(const PreparedDocument& document, + std::span alignments, + LayoutUnit maxLineWidth, TabStops tabStops, + std::span textIndents, + bool allowBreakAnywhere) + : m_preparedDocument(document), m_alignments(alignments), + m_maxLineWidth(std::clamp(maxLineWidth, kZero, kLarge)), m_textIndents(textIndents), + m_tabStops(tabStops), m_allowBreakAnywhere(allowBreakAnywhere), + m_resolvedAdvances(document.graphemes.size(), kZero) { + assert(document.breakOpportunities.size() == document.graphemeMap.codepointCount()); + assert(document.graphemes.size() == document.graphemeMap.graphemeCount()); + assert(document.graphemeAdvancePrefix.size() == document.graphemes.size() + 1); + assert(tabStops.firstStop >= kZero); + assert(tabStops.interval >= kZero); +} + +LayoutUnit DocumentGreedyLineBreaker::currentMaxLineWidth() const { + if (m_paragraphFirstLine) { + return m_maxLineWidth - m_textIndent; + } + return m_maxLineWidth; +} + +DocumentLayout DocumentGreedyLineBreaker::layoutPreparedDocument() { + assert(m_runInfos.empty()); + m_output.cookie = m_preparedDocument.cookie; + + assert(m_alignments.empty() || m_alignments.size() == 1 || + m_alignments.size() == m_preparedDocument.paragraphs.size()); + assert(m_textIndents.empty() || m_textIndents.size() == 1 || + m_textIndents.size() == m_preparedDocument.paragraphs.size()); + + for (size_t paragraphIndex = 0; paragraphIndex < m_preparedDocument.paragraphs.size(); ++paragraphIndex) { + const PreparedDocument::Paragraph& paragraph = m_preparedDocument.paragraphs[paragraphIndex]; + m_alignment = m_alignments.empty() ? TextAlignment::Start + : m_alignments[m_alignments.size() == 1 ? 0 : paragraphIndex]; + m_textIndent = + m_textIndents.empty() ? kZero : m_textIndents[m_textIndents.size() == 1 ? 0 : paragraphIndex]; + m_baseDirection = paragraph.baseDirection; + m_paragraphGraphemeBegin = paragraph.graphemeRange.min; + m_paragraphGraphemeEnd = paragraph.graphemeRange.max; + m_paragraphCodepointBegin = paragraph.paragraphRange.min; + m_paragraphCodepointEnd = paragraph.paragraphRange.max; + m_emptyLineMetrics = paragraph.emptyLineMetrics; + m_lineStart = m_paragraphGraphemeBegin; + m_nextGrapheme = m_paragraphGraphemeBegin; + m_lineWidth = kZero; + m_trailingWhitespaceMass = kZero; + m_postCandidateTrailingWhitespaceMass = kZero; + m_hasCandidate = false; + m_lineSlices.clear(); + m_runInfos.clear(); + m_paragraphFirstLine = true; + + for (RunIndex runIndex = paragraph.glyphRunRange.min; runIndex < paragraph.glyphRunRange.max; + ++runIndex) { + addRun(m_preparedDocument.glyphRuns[runIndex], runIndex, + runIndex + 1 == paragraph.glyphRunRange.max); + } + if (paragraph.glyphRunRange.empty()) { + commitLine(m_paragraphGraphemeBegin, LineEndKind::ParagraphEnd, kZero, kZero); + } + } + + return m_output; +} + +void DocumentGreedyLineBreaker::addRun(const GlyphRun& run, RunIndex preparedRunIndex, bool finishParagraph) { + const CodepointRange codepointRange = run.codepointRange; + assert(codepointRange.min <= codepointRange.max); + assert(codepointRange.max <= m_preparedDocument.graphemeMap.codepointCount()); + + const GraphemeRange graphemeRange = run.graphemeRange; + assert(graphemeRange.min == m_nextGrapheme); + assert(graphemeRange.max <= m_preparedDocument.graphemes.size()); + + const RunIndex runIndex = static_cast(m_runInfos.size()); + LineRunInfo runInfo{ + .preparedRunIndex = preparedRunIndex, + .level = run.level, + .metrics = run.metrics, + .fontHandle = run.fontHandle, + .glyphRange = run.glyphRange, + .graphemeRange = run.graphemeRange, + }; + m_runInfos.push_back(std::move(runInfo)); + + for (GraphemeIndex g = graphemeRange.min; g < graphemeRange.max; ++g) { + appendGrapheme(runIndex, graphemeRange.min, g, m_preparedDocument.graphemes[g]); + } + + if (finishParagraph) { + if (m_nextGrapheme > m_lineStart) { + const GraphemeIndex lastGrapheme = m_nextGrapheme - 1; + const uint32_t lastCodepoint = + m_preparedDocument.graphemeMap.codepointRangeForGrapheme(lastGrapheme).max - 1; + if (m_preparedDocument.breakOpportunities[lastCodepoint] == LineBreakKind::MustBreak) { + commitLine(m_nextGrapheme, LineEndKind::MandatoryBreak, m_lineWidth, + trimmedWidth(m_lineWidth, m_trailingWhitespaceMass)); + m_lineStart = m_nextGrapheme; + m_lineWidth = kZero; + m_trailingWhitespaceMass = kZero; + m_postCandidateTrailingWhitespaceMass = kZero; + m_hasCandidate = false; + } + } + commitLine(m_nextGrapheme, LineEndKind::ParagraphEnd, m_lineWidth, + trimmedWidth(m_lineWidth, m_trailingWhitespaceMass)); + m_lineStart = m_nextGrapheme; + m_paragraphFirstLine = false; + } +} + +void DocumentGreedyLineBreaker::appendGrapheme(RunIndex runIndex, GraphemeIndex runBase, GraphemeIndex g, + const GraphemeFlags& grapheme) { + assert(g == m_nextGrapheme); + + // A zero width is an explicit alignment-only container. It must not cause wrapping: + // lines are laid out as if unconstrained, then positioned around x = 0 according to + // the requested alignment. This is useful for point-anchored text. + const bool noWrap = m_maxLineWidth >= kLarge || m_maxLineWidth == kZero; + + // Step 1: is the boundary immediately before this grapheme cluster a break opportunity? + // (There is no boundary to consider before the very first grapheme of the paragraph.) + if (g > m_paragraphGraphemeBegin) { + const uint32_t boundaryCodepoint = m_preparedDocument.graphemeMap.toCodepoint(g) - 1; + const LineBreakKind kind = m_preparedDocument.breakOpportunities[boundaryCodepoint]; + + if (kind == LineBreakKind::MustBreak) { + // Mandatory breaks are unconditional: end the line here (consuming everything up to + // and including the previous grapheme) regardless of width or shaping safety. + commitLine(g, LineEndKind::MandatoryBreak, m_lineWidth, + trimmedWidth(m_lineWidth, m_trailingWhitespaceMass)); + m_lineStart = g; + m_lineWidth = kZero; + m_trailingWhitespaceMass = kZero; + m_postCandidateTrailingWhitespaceMass = kZero; + m_hasCandidate = false; + } else if (!noWrap && + (kind == LineBreakKind::AllowBreak || + (m_allowBreakAnywhere && !m_preparedDocument.graphemes[g - 1].unsafeToBreakAfter))) { + // Record (overwriting any earlier one) as the latest safe soft-wrap candidate for + // the still-open line -- greedy wrapping always prefers the rightmost candidate. + m_hasCandidate = true; + m_candidateGrapheme = g; + m_candidateWidth = m_lineWidth; + m_candidateTrimmedWidth = trimmedWidth(m_lineWidth, m_trailingWhitespaceMass); + m_postCandidateTrailingWhitespaceMass = kZero; + } + } + + // Step 2: append this grapheme cluster to the (possibly just-reset) open line. + if (m_lineSlices.empty() || m_lineSlices.back().runIndex != runIndex) { + m_lineSlices.push_back(PendingSlice{ runIndex, runBase, GraphemeRange{ g, g } }); + } + m_lineSlices.back().rangeInParagraph.max = g + 1; + + const LayoutUnit resolvedAdvance = resolveAdvance(g, grapheme); + m_resolvedAdvances[g] = resolvedAdvance; + m_lineWidth += resolvedAdvance; + m_trailingWhitespaceMass = + grapheme.trimmableWhitespace ? (m_trailingWhitespaceMass + resolvedAdvance) : kZero; + if (!noWrap && m_hasCandidate) { + m_postCandidateTrailingWhitespaceMass = + grapheme.trimmableWhitespace ? (m_postCandidateTrailingWhitespaceMass + resolvedAdvance) : kZero; + } + ++m_nextGrapheme; + + // Step 3: greedily wrap once the line overflows and a safe break point is available. If no + // candidate exists yet, keep accumulating (allowing the line to overflow) until one appears. + if (!noWrap && m_hasCandidate && m_lineWidth > currentMaxLineWidth()) { + commitLine(m_candidateGrapheme, LineEndKind::SoftWrap, m_candidateWidth, m_candidateTrimmedWidth); + m_lineStart = m_candidateGrapheme; + rebuildOpenLineFrom(m_candidateGrapheme); + } +} + +LayoutUnit DocumentGreedyLineBreaker::resolveAdvance(GraphemeIndex g, const GraphemeFlags& grapheme) const { + const LayoutUnit advance = + m_preparedDocument.graphemeAdvancePrefix[g + 1] - m_preparedDocument.graphemeAdvancePrefix[g]; + if (!grapheme.isTab || m_tabStops.interval == kZero) { + return advance; + } + + if (m_lineWidth < m_tabStops.firstStop) { + return m_tabStops.firstStop - m_lineWidth; + } + + const LayoutUnit remainder = (m_lineWidth - m_tabStops.firstStop) % m_tabStops.interval; + return remainder == kZero ? m_tabStops.interval : m_tabStops.interval - remainder; +} + +void DocumentGreedyLineBreaker::rebuildOpenLineFrom(GraphemeIndex begin) { + m_lineWidth = kZero; + m_trailingWhitespaceMass = kZero; + m_postCandidateTrailingWhitespaceMass = kZero; + m_hasCandidate = false; + + for (GraphemeIndex g = begin; g < m_nextGrapheme; ++g) { + const GraphemeFlags& grapheme = m_preparedDocument.graphemes[g]; + const LayoutUnit resolvedAdvance = resolveAdvance(g, grapheme); + m_resolvedAdvances[g] = resolvedAdvance; + m_lineWidth += resolvedAdvance; + m_trailingWhitespaceMass = + grapheme.trimmableWhitespace ? m_trailingWhitespaceMass + resolvedAdvance : kZero; + } +} + +LayoutUnit DocumentGreedyLineBreaker::sliceWidth(GraphemeRange range) const { + LayoutUnit width = kZero; + for (GraphemeIndex g = range.min; g < range.max; ++g) { + width += m_resolvedAdvances[g]; + } + return width; +} + +void DocumentGreedyLineBreaker::commitLine(GraphemeIndex cutGrapheme, LineEndKind kind, + LayoutUnit contentAdvance, LayoutUnit trimmedAdvance) { + // Slices are contiguous and sorted by construction, so at most one of them straddles the + // cut point; everything before it is included whole, everything from it onward -- possibly + // truncated at its start -- is left in place to seed the next line. + m_outputSlices.clear(); + + size_t i = 0; + for (; i < m_lineSlices.size(); ++i) { + const PendingSlice& slice = m_lineSlices[i]; + if (slice.rangeInParagraph.min >= cutGrapheme) { + break; // This slice (and everything after it) belongs entirely to the next line. + } + const GraphemeIndex sliceEnd = std::min(slice.rangeInParagraph.max, cutGrapheme); + m_outputSlices.push_back(LineRunSlice{ + .runIndex = slice.runIndex, + .graphemeRange = + GraphemeRange{ slice.rangeInParagraph.min - slice.runBase, sliceEnd - slice.runBase }, + }); + if (slice.rangeInParagraph.max > cutGrapheme) { + break; // Straddles the cut; its remainder (from cutGrapheme onward) stays pending. + } + } + + // 1. Collect run slices for this line. + m_visualSlices = m_outputSlices; + + // 2. Sort to visual order using UAX #9 L2 rule. + if (!m_visualSlices.empty() && !m_runInfos.empty()) { + BiDiLevel maxLevel = 0; + BiDiLevel minOddLevel = 255; + for (const LineRunSlice& slice : m_visualSlices) { + const RunIndex runIdx = slice.runIndex; + if (runIdx < m_runInfos.size()) { + const BiDiLevel level = m_runInfos[runIdx].level; + if (level > maxLevel) { + maxLevel = level; + } + if ((level % 2 != 0) && level < minOddLevel) { + minOddLevel = level; + } + } + } + + if (minOddLevel != 255) { + for (int l = static_cast(maxLevel); l >= static_cast(minOddLevel); --l) { + const auto targetLevel = static_cast(l); + size_t start = 0; + while (start < m_visualSlices.size()) { + if (m_visualSlices[start].runIndex < m_runInfos.size() && + m_runInfos[m_visualSlices[start].runIndex].level >= targetLevel) { + size_t end = start + 1; + while (end < m_visualSlices.size() && + m_visualSlices[end].runIndex < m_runInfos.size() && + m_runInfos[m_visualSlices[end].runIndex].level >= targetLevel) { + ++end; + } + std::reverse(m_visualSlices.begin() + static_cast(start), + m_visualSlices.begin() + static_cast(end)); + start = end; + } else { + ++start; + } + } + } + } + } + + // 3. Apply horizontal alignment and textIndent. + Direction concreteAlignDir = Direction::LeftToRight; + switch (m_alignment) { + case TextAlignment::Left: + concreteAlignDir = Direction::LeftToRight; + break; + case TextAlignment::Right: + concreteAlignDir = Direction::RightToLeft; + break; + case TextAlignment::Start: + case TextAlignment::Justify: + concreteAlignDir = m_baseDirection; + break; + case TextAlignment::End: + concreteAlignDir = + m_baseDirection == Direction::LeftToRight ? Direction::RightToLeft : Direction::LeftToRight; + break; + case TextAlignment::Center: + break; + } + + const bool isFirstLine = m_paragraphFirstLine; + const LayoutUnit lineIndent = isFirstLine ? m_textIndent : kZero; + + LayoutUnit originX = kZero; + const LayoutUnit remainingSpace = + m_maxLineWidth > trimmedAdvance ? (m_maxLineWidth - trimmedAdvance) : kZero; + + if (m_maxLineWidth == kZero) { + // With no alignment container, place the selected edge/center at x = 0. + // Use trimmedAdvance consistently with finite-width alignment, so trailing + // whitespace does not move the visible text anchor. + if (m_alignment == TextAlignment::Center) { + originX = -half(trimmedAdvance); + } else if (concreteAlignDir == Direction::RightToLeft) { + originX = -trimmedAdvance; + } + } else if (m_alignment == TextAlignment::Center) { + // Center alignment: shift by remainingSpace / 2 + direction-aware indent + if (m_baseDirection == Direction::RightToLeft) { + originX = half(remainingSpace) - lineIndent; + } else { + originX = half(remainingSpace) + lineIndent; + } + } else if (concreteAlignDir == Direction::RightToLeft) { + originX = remainingSpace - lineIndent; + } else { + originX = lineIndent; + } + + // 4. Materialize directly renderable glyph-run slices in visual order. + const auto glyphRunStart = static_cast(m_output.glyphRuns.size()); + LayoutUnit visualCursor = originX; + for (const LineRunSlice& slice : m_visualSlices) { + const LineRunInfo& info = m_runInfos[slice.runIndex]; + GlyphRange glyphRange{ info.glyphRange.min, info.glyphRange.min }; + bool foundGlyph = false; + + for (GlyphIndex glyphIndex = info.glyphRange.min; glyphIndex < info.glyphRange.max; ++glyphIndex) { + const GraphemeIndex g = m_preparedDocument.glyphs[glyphIndex].graphemeIndex; + if (g >= slice.graphemeRange.min && g < slice.graphemeRange.max) { + if (!foundGlyph) { + glyphRange.min = glyphIndex; + foundGlyph = true; + } + glyphRange.max = glyphIndex + 1; + } + } + + const GraphemeIndex documentSliceMin = info.graphemeRange.min + slice.graphemeRange.min; + const GraphemeIndex documentSliceMax = info.graphemeRange.min + slice.graphemeRange.max; + const LayoutUnit sliceWidth = this->sliceWidth({ documentSliceMin, documentSliceMax }); + LayoutUnit glyphOrigin = kZero; + if (foundGlyph) { + glyphOrigin = m_preparedDocument.glyphs[glyphRange.min].xOffset; + for (GlyphIndex glyphIndex = glyphRange.min + 1; glyphIndex < glyphRange.max; ++glyphIndex) { + glyphOrigin = std::min(glyphOrigin, m_preparedDocument.glyphs[glyphIndex].xOffset); + } + } + m_output.glyphRuns.push_back(LayoutGlyphRun{ + .preparedRunIndex = info.preparedRunIndex, + .glyphRange = glyphRange, + .xOffset = visualCursor - glyphOrigin, + .yOffset = kZero, + }); + visualCursor += sliceWidth; + } + const auto glyphRunEnd = static_cast(m_output.glyphRuns.size()); + const RunRange glyphRunRange{ glyphRunStart, glyphRunEnd }; + + // 5. Compute line vertical metrics. + LayoutUnit maxAscent = kZero; + LayoutUnit maxDescent = kZero; + LayoutUnit maxLeading = kZero; + bool haveVerticalMetrics = false; + + for (const auto& slice : m_outputSlices) { + if (slice.runIndex < m_runInfos.size()) { + const VerticalMetrics& vm = m_runInfos[slice.runIndex].metrics; + LayoutUnit runAscent = vm.ascent; + LayoutUnit runDescent = vm.descent; + const LayoutUnit runLeading = vm.lineGap; + + // Empty/control runs can carry zero metrics. They must not turn + // negative explicit leading into zero while aggregating a line. + if (runAscent == kZero && runDescent == kZero && runLeading == kZero) { + continue; + } + + if (runAscent > maxAscent) { + maxAscent = runAscent; + } + if (runDescent < maxDescent) { + maxDescent = runDescent; + } + if (!haveVerticalMetrics || runLeading > maxLeading) { + maxLeading = runLeading; + } + haveVerticalMetrics = true; + } + } + if (m_outputSlices.empty()) { + const VerticalMetrics& metrics = m_runInfos.empty() ? m_emptyLineMetrics : m_runInfos.back().metrics; + maxAscent = metrics.ascent; + maxDescent = metrics.descent; + maxLeading = metrics.lineGap; + } + + // 6. Distribute baselines. Leading is half-leading on either side of the content box, + // as in a CSS line box. Keep m_cursor at the top of the line box so adjacent line boxes + // remain contiguous. + const LayoutUnit halfLeading = half(maxLeading); + const LayoutUnit baselineY = m_cursor + maxAscent + halfLeading; + m_cursor += maxAscent - maxDescent + maxLeading; + + for (RunIndex run = glyphRunRange.min; run < glyphRunRange.max; ++run) { + const RunIndex preparedRunIndex = m_output.glyphRuns[run].preparedRunIndex; + m_output.glyphRuns[run].yOffset = + baselineY - m_preparedDocument.glyphRuns[preparedRunIndex].verticalAlign; + } + + // 7. Source coverage and emit LayoutLine. + const GraphemeRange lineGraphemeRange{ m_lineStart, cutGrapheme }; + CodepointRange lineCodepointRange{ 0, 0 }; + + if (lineGraphemeRange.min < m_preparedDocument.graphemeMap.graphemeCount() && + lineGraphemeRange.max <= m_preparedDocument.graphemeMap.graphemeCount() && + lineGraphemeRange.min < lineGraphemeRange.max) { + lineCodepointRange = CodepointRange{ + m_preparedDocument.graphemeMap.toCodepoint(lineGraphemeRange.min), + m_preparedDocument.graphemeMap.codepointRangeForGrapheme(lineGraphemeRange.max - 1).max, + }; + } else if (lineGraphemeRange.min < m_preparedDocument.graphemeMap.graphemeCount()) { + const CodepointIndex cp = m_preparedDocument.graphemeMap.toCodepoint(lineGraphemeRange.min); + lineCodepointRange = CodepointRange{ cp, cp }; + } else { + const CodepointIndex totalCp = m_preparedDocument.graphemeMap.codepointCount(); + lineCodepointRange = CodepointRange{ totalCp, totalCp }; + } + + const LayoutLine layoutLine{ + .graphemeRange = lineGraphemeRange, + .codepointRange = lineCodepointRange, + .baseDirection = m_baseDirection, + .glyphRunRange = glyphRunRange, + .endKind = kind, + .originX = originX, + .baselineY = baselineY, + .ascent = maxAscent, + .descent = maxDescent, + .leading = maxLeading, + .width = contentAdvance, + .trimmedWidth = trimmedAdvance, + }; + + const LayoutRange lineVerticalBounds{ baselineY - maxAscent - halfLeading, + baselineY - maxDescent + halfLeading }; + const LayoutRange lineHorizontalBounds{ originX, originX + contentAdvance }; + const LayoutRange lineHorizontalTrimmedBounds{ originX, originX + trimmedAdvance }; + + if (m_output.lines.empty()) { + m_output.verticalTextBounds = lineVerticalBounds; + m_output.horizontalTextBounds = lineHorizontalBounds; + m_output.horizontalTrimmedTextBounds = lineHorizontalTrimmedBounds; + } else { + m_output.verticalTextBounds = m_output.verticalTextBounds.union_(lineVerticalBounds); + m_output.horizontalTextBounds = m_output.horizontalTextBounds.union_(lineHorizontalBounds); + m_output.horizontalTrimmedTextBounds = + m_output.horizontalTrimmedTextBounds.union_(lineHorizontalTrimmedBounds); + } + + m_output.lines.push_back(layoutLine); + m_paragraphFirstLine = false; + + if (i < m_lineSlices.size() && m_lineSlices[i].rangeInParagraph.min < cutGrapheme) { + m_lineSlices[i].rangeInParagraph.min = cutGrapheme; + } + if (i > 0) { + m_lineSlices.erase(m_lineSlices.begin(), m_lineSlices.begin() + static_cast(i)); + } +} + +DocumentLayout layoutPreparedDocument(const PreparedDocument& document, + std::span alignments, LayoutUnit maxLineWidth, + TabStops tabStops, std::span textIndents, + bool allowBreakAnywhere) { + return DocumentGreedyLineBreaker(document, alignments, maxLineWidth, tabStops, textIndents, + allowBreakAnywhere) + .layoutPreparedDocument(); +} + +namespace { + +struct VisualRun { + GraphemeRange graphemeRange; + GraphemeIndex preparedGraphemeMin; + GlyphRange glyphRange; + BiDiLevel level; + LayoutUnit left; + LayoutUnit right; +}; + +LayoutUnit visualRunLeft(const PreparedDocument& document, const LayoutGlyphRun& layoutRun) { + LayoutUnit glyphOrigin = kZero; + if (!layoutRun.glyphRange.empty()) { + assert(layoutRun.glyphRange.max <= document.glyphs.size()); + glyphOrigin = document.glyphs[layoutRun.glyphRange.min].xOffset; + for (GlyphIndex glyph = layoutRun.glyphRange.min + 1; glyph < layoutRun.glyphRange.max; ++glyph) { + glyphOrigin = std::min(glyphOrigin, document.glyphs[glyph].xOffset); + } + } + return layoutRun.xOffset + glyphOrigin; +} + +template +void forEachVisualRun(const PreparedDocument& document, const DocumentLayout& layout, const LayoutLine& line, + Callback&& callback) { + if (line.glyphRunRange.empty()) { + return; + } + + assert(line.glyphRunRange.max <= layout.glyphRuns.size()); + RunIndex layoutRunIndex = line.glyphRunRange.min; + LayoutUnit left = visualRunLeft(document, layout.glyphRuns[layoutRunIndex]); + while (layoutRunIndex < line.glyphRunRange.max) { + const LayoutGlyphRun& layoutRun = layout.glyphRuns[layoutRunIndex]; + assert(layoutRun.preparedRunIndex < document.glyphRuns.size()); + const GlyphRun& preparedRun = document.glyphRuns[layoutRun.preparedRunIndex]; + const GraphemeRange graphemeRange = preparedRun.graphemeRange.intersection(line.graphemeRange); + assert(!graphemeRange.empty()); + + const RunIndex nextIndex = layoutRunIndex + 1; + const LayoutUnit right = nextIndex < line.glyphRunRange.max + ? visualRunLeft(document, layout.glyphRuns[nextIndex]) + : line.originX + line.width; + callback(VisualRun{ graphemeRange, preparedRun.graphemeRange.min, layoutRun.glyphRange, + preparedRun.level, left, right }); + left = right; + layoutRunIndex = nextIndex; + } +} + +LayoutUnit proportionalAdvanceDocument(LayoutUnit advance, uint32_t numerator, uint32_t denominator) { +#ifdef LAYOUT_FLOAT + return advance * static_cast(numerator) / static_cast(denominator); +#else + return static_cast(static_cast(advance) * numerator / denominator); +#endif +} + +LayoutUnit boundaryPosition(const PreparedDocument& document, const VisualRun& run, GraphemeIndex boundary) { + assert(boundary >= run.graphemeRange.min && boundary <= run.graphemeRange.max); + LayoutUnit advance = kZero; + if (run.graphemeRange.distance() == 1 && document.graphemes[run.graphemeRange.min].isTab) { + advance = boundary == run.graphemeRange.min ? kZero : run.right - run.left; + } else { + assert(document.graphemeAdvancePrefix.size() == document.graphemes.size() + 1); + advance = + document.graphemeAdvancePrefix[boundary] - document.graphemeAdvancePrefix[run.graphemeRange.min]; + if (boundary == run.graphemeRange.max) { + return run.level % 2 == 0 ? run.left + advance : run.right - advance; + } + const LayoutUnit boundaryAdvance = + document.graphemeAdvancePrefix[boundary + 1] - document.graphemeAdvancePrefix[boundary]; + if (boundaryAdvance != kZero) { + return run.level % 2 == 0 ? run.left + advance : run.right - advance; + } + + GraphemeIndex clusterStart = run.graphemeRange.min; + GraphemeIndex nextCluster = run.graphemeRange.max; + const Glyph* clusterGlyph = nullptr; + uint32_t clusterGlyphCount = 0; + for (GlyphIndex glyphIndex = run.glyphRange.min; glyphIndex < run.glyphRange.max; ++glyphIndex) { + const Glyph& glyph = document.glyphs[glyphIndex]; + const GraphemeIndex glyphCluster = run.preparedGraphemeMin + glyph.graphemeIndex; + if (glyphCluster < boundary && glyphCluster >= clusterStart) { + if (glyphCluster > clusterStart) { + clusterStart = glyphCluster; + clusterGlyph = &glyph; + clusterGlyphCount = 1; + } else { + clusterGlyph = clusterGlyph == nullptr ? &glyph : clusterGlyph; + ++clusterGlyphCount; + } + } + } + for (GlyphIndex glyphIndex = run.glyphRange.min; glyphIndex < run.glyphRange.max; ++glyphIndex) { + const GraphemeIndex glyphCluster = + run.preparedGraphemeMin + document.glyphs[glyphIndex].graphemeIndex; + if (glyphCluster > clusterStart) { + nextCluster = std::min(nextCluster, glyphCluster); + } + } + + const GraphemeIndex componentCount = nextCluster - clusterStart; + if (clusterGlyph != nullptr && clusterGlyphCount == 1 && componentCount > 1 && + boundary < nextCluster) { + const LayoutUnit clusterAdvance = + clusterGlyph->xAdvance >= kZero ? clusterGlyph->xAdvance : -clusterGlyph->xAdvance; + advance = document.graphemeAdvancePrefix[clusterStart] - + document.graphemeAdvancePrefix[run.graphemeRange.min] + + proportionalAdvanceDocument(clusterAdvance, boundary - clusterStart, componentCount); + } + } + return run.level % 2 == 0 ? run.left + advance : run.right - advance; +} + +LineIndex lineForPoint(const DocumentLayout& layout, LayoutUnit y) { + assert(!layout.lines.empty()); + LineIndex bestLine = 0; + double bestDistance = std::numeric_limits::infinity(); + for (LineIndex index = 0; index < layout.lines.size(); ++index) { + const LayoutLine& line = layout.lines[index]; + const LayoutUnit halfLeading = half(line.leading); + const LayoutUnit top = line.baselineY - line.ascent - halfLeading; + const LayoutUnit bottom = line.baselineY - line.descent + halfLeading; + const double distance = y < top ? static_cast(top - y) + : y > bottom ? static_cast(y - bottom) + : 0.0; + if (distance < bestDistance) { + bestDistance = distance; + bestLine = index; + } + } + return bestLine; +} + +size_t paragraphForCaret(const PreparedDocument& document, GraphemeIndex grapheme, CaretAffinity affinity) { + assert(!document.paragraphs.empty()); + if (affinity == CaretAffinity::Upstream) { + for (size_t index = 0; index < document.paragraphs.size(); ++index) { + const auto& paragraph = document.paragraphs[index]; + if (grapheme <= paragraph.graphemeRange.min) { + return index == 0 ? 0 : index - 1; + } + if (grapheme <= paragraph.graphemeRange.max) { + return index; + } + } + return document.paragraphs.size() - 1; + } + + for (size_t index = 0; index < document.paragraphs.size(); ++index) { + const auto& paragraph = document.paragraphs[index]; + if (grapheme < paragraph.graphemeRange.min) { + // Downstream affinity in a separator gap attaches to the following paragraph. + return index; + } + if (grapheme <= paragraph.graphemeRange.min || grapheme < paragraph.graphemeRange.max) { + return index; + } + } + return document.paragraphs.size() - 1; +} + +LineIndex lineForCaret(const DocumentLayout& layout, size_t paragraphIndex, GraphemeIndex grapheme, + CaretAffinity affinity) { + assert(!layout.lines.empty()); + size_t currentParagraph = 0; + auto begin = layout.lines.begin(); + while (currentParagraph < paragraphIndex) { + while (begin != layout.lines.end() && begin->endKind != LineEndKind::ParagraphEnd) { + ++begin; + } + if (begin != layout.lines.end()) { + ++begin; + } + ++currentParagraph; + } + auto end = begin; + while (end != layout.lines.end() && end->endKind != LineEndKind::ParagraphEnd) { + ++end; + } + if (end != layout.lines.end()) { + ++end; + } + if (begin == end) { + return static_cast(layout.lines.size() - 1); + } + auto line = + affinity == CaretAffinity::Upstream + ? std::lower_bound(begin, end, grapheme, + [](const LayoutLine& candidate, GraphemeIndex value) { + return candidate.graphemeRange.max < value; + }) + : std::lower_bound(begin, end, grapheme, [](const LayoutLine& candidate, GraphemeIndex value) { + return candidate.graphemeRange.max <= value; + }); + if (line == end) { + line = end - 1; + } + return static_cast(line - layout.lines.begin()); +} + +BiDiLevel baseLevel(Direction direction) { + return direction == Direction::RightToLeft ? BiDiLevel{ 1 } : BiDiLevel{ 0 }; +} + +} // namespace + +CaretPosition caretPosition(const PreparedDocument& document, const DocumentLayout& layout, + CaretIndex index) { + assert(layout.cookie == document.cookie); + assert(index.grapheme <= document.graphemeMap.graphemeCount()); + assert(!layout.lines.empty()); + + const size_t paragraphIndex = paragraphForCaret(document, index.grapheme, index.affinity); + const LineIndex lineIndex = lineForCaret(layout, paragraphIndex, index.grapheme, index.affinity); + const LayoutLine& line = layout.lines[lineIndex]; + CaretPosition result{ line.originX, lineIndex, baseLevel(line.baseDirection) }; + const GraphemeIndex attachedGrapheme = + index.affinity == CaretAffinity::Upstream && index.grapheme > 0 ? index.grapheme - 1 : index.grapheme; + bool found = false; + CaretPosition fallback = result; + bool hasFallback = false; + forEachVisualRun(document, layout, line, [&](const VisualRun& run) { + if (run.graphemeRange.contains(attachedGrapheme)) { + result.x = boundaryPosition(document, run, index.grapheme); + result.level = run.level; + found = true; + } else if (run.graphemeRange.min == index.grapheme || run.graphemeRange.max == index.grapheme) { + fallback.x = boundaryPosition(document, run, index.grapheme); + fallback.level = run.level; + hasFallback = true; + } + }); + return !found && hasFallback ? fallback : result; +} + +CaretIndex hitTestPoint(const PreparedDocument& document, const DocumentLayout& layout, LayoutUnit x, + LayoutUnit y) { + assert(layout.cookie == document.cookie); + assert(!layout.lines.empty()); + const LineIndex lineIndex = lineForPoint(layout, y); + const LayoutLine& line = layout.lines[lineIndex]; + CaretIndex best{ line.graphemeRange.min, CaretAffinity::Downstream }; + double bestDistance = std::numeric_limits::infinity(); + forEachVisualRun(document, layout, line, [&](const VisualRun& run) { + for (GraphemeIndex boundary = run.graphemeRange.min; boundary <= run.graphemeRange.max; ++boundary) { + const LayoutUnit candidateX = boundaryPosition(document, run, boundary); + const double distance = std::abs(static_cast(x) - static_cast(candidateX)); + if (distance < bestDistance) { + bestDistance = distance; + best = { boundary, boundary == run.graphemeRange.max ? CaretAffinity::Upstream + : CaretAffinity::Downstream }; + } + } + }); + return best; +} + +void selectionRects(const PreparedDocument& document, const DocumentLayout& layout, GraphemeRange selection, + function_ref onRect) { + assert(layout.cookie == document.cookie); + if (selection.min > selection.max || selection.max > document.graphemeMap.graphemeCount() || + selection.empty()) { + return; + } + for (LineIndex lineIndex = 0; lineIndex < layout.lines.size(); ++lineIndex) { + const LayoutLine& line = layout.lines[lineIndex]; + const GraphemeRange selectedLine = line.graphemeRange.intersection(selection); + if (selectedLine.empty()) { + continue; + } + forEachVisualRun(document, layout, line, [&](const VisualRun& run) { + const GraphemeRange selectedRun = run.graphemeRange.intersection(selection); + if (selectedRun.empty()) { + return; + } + const LayoutUnit a = boundaryPosition(document, run, selectedRun.min); + const LayoutUnit b = boundaryPosition(document, run, selectedRun.max); + onRect(SelectionRect{ lineIndex, std::min(a, b), std::max(a, b) }); + }); + } +} + +bool rasterize(const ActiveFont& activeFont, const FontHandle& handle, uint32_t glyphId, + const RasterizationOptions& options, + function_ref callback) { + if (options.horizontalScale <= 0 || !handle || !activeFont.hbFont) { + return false; + } + FT_Face ftFace = static_cast(activeFont.ftFace); + + if (FT_Load_Glyph(ftFace, glyphId, activeFont.loadFlags | FT_LOAD_COLOR) != 0) + return false; + bool outline = ftFace->glyph->format == FT_GLYPH_FORMAT_OUTLINE; + if (ftFace->glyph->format == FT_GLYPH_FORMAT_OUTLINE) { + FT_Matrix matrix{ static_cast(0x10000 * options.horizontalScale), 0, 0, 0x10000 }; + FT_Outline_Transform(&ftFace->glyph->outline, &matrix); + } else if (ftFace->glyph->format == FT_GLYPH_FORMAT_SVG) { + // + } else { + return false; + } + if (FT_Render_Glyph(ftFace->glyph, FT_RENDER_MODE_NORMAL) != 0) + return false; + const FT_Bitmap& bitmap = ftFace->glyph->bitmap; + const auto format = bitmap.pixel_mode == FT_PIXEL_MODE_BGRA ? RasterizedGlyph::Format::BGRA8 + : RasterizedGlyph::Format::Mask8; + if (!((bitmap.pixel_mode == FT_PIXEL_MODE_GRAY || bitmap.pixel_mode == FT_PIXEL_MODE_BGRA) && + (bitmap.buffer || bitmap.width == 0 || bitmap.rows == 0))) + return false; + int horizontalScale = outline ? options.horizontalScale : 1; + callback( + RasterizedGlyph{ + .left = ftFace->glyph->bitmap_left, + .top = ftFace->glyph->bitmap_top, + .width = bitmap.width, + .height = bitmap.rows, + .pitch = bitmap.pitch, + .format = format, + .bytesPerPixel = format == RasterizedGlyph::Format::BGRA8 ? 4u : 1u, + .horizontalScale = horizontalScale, + }, + bitmap.buffer); + return true; +} + +} // namespace Brisk::TextEngine diff --git a/src/graphics/TextEngine/include/text_layout/BreakIterator.hpp b/src/graphics/TextEngine/include/text_layout/BreakIterator.hpp new file mode 100644 index 00000000..bb7bd11c --- /dev/null +++ b/src/graphics/TextEngine/include/text_layout/BreakIterator.hpp @@ -0,0 +1,303 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace Brisk::TextEngine { + +/** + * @brief Abstract base class for Unicode text boundary iterators. + * + * Traverses boundary positions in UTF-32 text. Iteration protocol: + * 1. The first call to nextBreak() returns index 0. + * 2. Subsequent calls return intermediate boundary positions in ascending order. + * 3. The final boundary returned is the text length (clamped to maxTextLength). + * 4. Further calls return UINT32_MAX to indicate the end of iteration. + * + * @note The referenced text buffer must outlive the BreakIterator instance or until setText() is called + * again. + */ +class BreakIterator { +public: + /// Maximum number of UTF-32 codepoints processed by the iterator (256M). + static constexpr uint32_t maxTextLength = 256 * 1024 * 1024; // 256M codepoints + + virtual ~BreakIterator() = default; + + /** + * @brief Sets the text to iterate over and resets the iterator state. + * @param text The UTF-32 text view to process. Must outlive iteration. + */ + void setText(std::u32string_view text); + + /** + * @brief Resets the iterator state to the beginning of the currently set text. + */ + void reset(); + + /** + * @brief Counts the total number of boundaries in the text and resets the iterator state. + * + * After counting, the iterator is reset back to the beginning of the text. + * @return The total number of break positions in the current text. + */ + virtual uint32_t countBreaks(); + + /** + * @brief Advances to and returns the next boundary offset. + * @return Codepoint offset of the next boundary, or UINT32_MAX if iteration is complete. + */ + uint32_t nextBreak(); + + /** + * @brief Returns the next text fragment bounded by consecutive break positions. + * + * Automatically advances the break position if not yet started. + * @return A substring view between consecutive breaks, or an empty view if finished. + */ + std::u32string_view nextFragment(); + +protected: + /** + * @brief Implementation hook for setting text and initializing subclass state. + */ + virtual void doSetText() = 0; + + /** + * @brief Implementation hook for computing the next boundary position. + * @return Next boundary offset, or UINT32_MAX when complete. + */ + virtual uint32_t doNextBreak() = 0; + + std::u32string_view m_text{}; ///< Clamped UTF-32 text view being iterated over. + + static constexpr uint32_t end = UINT32_MAX; + static constexpr uint32_t unstarted = UINT32_MAX - 1; + + uint32_t m_lastBreakPos{ unstarted }; +}; + +/** + * @brief Line-break iterator implementing Unicode Standard Annex #14 (UAX #14). + * + * Breaks text into line segments based on language rules and break opportunity modes. + */ +class LineBreakIterator : public BreakIterator { +public: + /** + * @brief Break opportunity reporting mode. + */ + enum class Mode { + MandatoryOnly, ///< Report mandatory (hard) line breaks only. + MandatoryAndAllowed ///< Report both mandatory (hard) and allowed (soft) line breaks. + }; + + /** + * @brief Constructs a line break iterator with the given language and mode. + * @param lang BCP 47 language tag or ISO 639 code (e.g. "en", "zh") for tailoring, or empty for default. + * @param mode Whether to report mandatory breaks only or both mandatory and allowed breaks. + */ + explicit LineBreakIterator(std::string lang = {}, Mode mode = Mode::MandatoryAndAllowed); + ~LineBreakIterator() override = default; + + uint32_t countBreaks() override; + +protected: + void doSetText() override; + uint32_t doNextBreak() override; + +private: + std::string m_lang; + Mode m_mode; + std::vector m_breaks; + size_t m_currentIndex{ 0 }; +}; + +/** + * @brief Extended grapheme cluster boundary iterator implementing Unicode Standard Annex #29 (UAX #29). + * + * Iterates over user-perceived character boundaries, correctly handling combining marks, + * emoji modifiers, and Zero Width Joiner (ZWJ) sequences. + */ +class GraphemeBreakIterator : public BreakIterator { +public: + GraphemeBreakIterator() = default; + ~GraphemeBreakIterator() override = default; + +protected: + void doSetText() override; + uint32_t doNextBreak() override; + +private: + int32_t m_state{ 0 }; +}; + +/** + * @brief Paragraph boundary iterator. + * + * Identifies paragraph boundaries delineated by LF (U+000A), CR (U+000D), CRLF pairs + * (treated atomically), U+001C–U+001E (IS4–IS2), NEL (U+0085), or Paragraph Separator PS (U+2029). + */ +class ParagraphBreakIterator : public BreakIterator { +public: + ParagraphBreakIterator() = default; + ~ParagraphBreakIterator() override = default; + +protected: + void doSetText() override; + uint32_t doNextBreak() override; +}; + +/** + * @brief Word boundary iterator using libunibreak's UAX #29 implementation. + * + * Segments text into words, attaching any trailing non-word characters (spaces, punctuation, symbols) + * to the preceding word. Leading non-word characters form their own fragment. + */ +class WordBreakIterator : public BreakIterator { +public: + WordBreakIterator() = default; + ~WordBreakIterator() override = default; + + uint32_t countBreaks() override; + +protected: + void doSetText() override; + uint32_t doNextBreak() override; + +private: + std::vector m_breaks; + size_t m_currentIndex{ 0 }; +}; + +/** + * @brief Base paragraph direction policy for Unicode Bidirectional Algorithm. + */ +enum class BaseDirection : uint8_t { + DefaultLTR, ///< Auto-detect base direction from first strong character; defaults to LTR if none. + DefaultRTL, ///< Auto-detect base direction from first strong character; defaults to RTL if none. + LeftToRight, ///< Force Left-to-Right base direction (level 0). + RightToLeft ///< Force Right-to-Left base direction (level 1). +}; + +using BiDiLevel = uint8_t; + +/** + * @brief Direction of a resolved BiDi run. + */ +enum class Direction : uint8_t { + LeftToRight = 0, ///< Left-to-Right direction (even embedding level). + RightToLeft = 1 ///< Right-to-Left direction (odd embedding level). +}; + +/** + * @brief Maps a BiDi embedding level to its resolved Direction. + */ +[[nodiscard]] constexpr Direction directionFromLevel(BiDiLevel level) noexcept { + return (level & 1) != 0 ? Direction::RightToLeft : Direction::LeftToRight; +} + +/** + * @brief Information about a resolved unidirectional text run from BiDi analysis. + */ +struct BiDiRun { + Range range{}; ///< Half-open codepoint range of the run within the text. + uint32_t visualIndex{ 0 }; ///< 0-based visual index of the run (display order from left to right). + uint8_t level{ 0 }; ///< Resolved Unicode BiDi embedding level. + + /// Returns true if this run has Right-to-Left direction (odd level). + [[nodiscard]] constexpr bool isRTL() const noexcept { + return (level & 1) != 0; + } + + /// Returns true if this run has Left-to-Right direction (even level). + [[nodiscard]] constexpr bool isLTR() const noexcept { + return (level & 1) == 0; + } + + /// Returns the Direction enum for this run. + [[nodiscard]] constexpr Direction direction() const noexcept { + return directionFromLevel(level); + } +}; + +/** + * @brief Unicode Bidirectional Algorithm (UAX #9) iterator. + * + * Follows the BreakIterator iteration protocol to traverse BiDi runs in logical order: + * 1. The first call to nextBreak() returns index 0. + * 2. Subsequent calls return boundary positions between runs of differing embedding levels, + * ending at the text length. + * 3. Further calls return UINT32_MAX to indicate the end of iteration. + * + * In addition to break positions and text fragments, callers can query the resolved + * BiDiRun descriptor or embedding level of the current fragment, or use nextRun() directly. + * + * @note The referenced text buffer must outlive the BiDiIterator instance or until setText() + * is called again. + */ +class BiDiIterator : public BreakIterator { +public: + /** + * @brief Constructs a BiDi iterator. + * @param baseDirection The default base direction policy to use. + */ + explicit BiDiIterator(BaseDirection baseDirection = BaseDirection::DefaultLTR); + ~BiDiIterator() override = default; + + /** + * @brief Sets the text to process and the paragraph base direction policy. + * @param text One UTF-32 paragraph to process. It must not contain paragraph separators + * (LF, CR, CRLF, or PS); use ParagraphBreakIterator to segment multi-paragraph text first. + * @param baseDirection The base direction policy for BiDi resolution. + */ + void setText(std::u32string_view text, BaseDirection baseDirection = BaseDirection::DefaultLTR); + + /** + * @brief Advances to and returns the next unidirectional BiDi run. + * @return Const reference to the next BiDiRun, or a run with an empty range when iteration is complete. + */ + const BiDiRun& nextRun(); + + /** + * @brief Returns the resolved BiDi run for the most recently retrieved fragment/break. + * @return Const reference to the current BiDiRun, or a static empty run if unstarted or finished. + */ + [[nodiscard]] const BiDiRun& currentRun() const noexcept; + + /** + * @brief Returns the text fragment corresponding to the current BiDi run. + * @return A substring view of the current run, or an empty view if unstarted or finished. + */ + [[nodiscard]] std::u32string_view currentFragment() const noexcept; + + /** + * @brief Returns the base direction policy configured for this iterator. + */ + [[nodiscard]] BaseDirection baseDirection() const noexcept { + return m_baseDirection; + } + + /** + * @brief Returns the resolved paragraph base embedding level (0 for LTR, 1 for RTL). + */ + [[nodiscard]] uint8_t paragraphLevel() const noexcept { + return m_paragraphLevel; + } + +protected: + void doSetText() override; + uint32_t doNextBreak() override; + +private: + BaseDirection m_baseDirection{ BaseDirection::DefaultLTR }; + uint8_t m_paragraphLevel{ 0 }; + std::vector m_runs; + size_t m_currentRunIndex{ 0 }; +}; + +} // namespace Brisk::TextEngine diff --git a/src/graphics/TextEngine/include/text_layout/FontDatabase.hpp b/src/graphics/TextEngine/include/text_layout/FontDatabase.hpp new file mode 100644 index 00000000..edcc9de4 --- /dev/null +++ b/src/graphics/TextEngine/include/text_layout/FontDatabase.hpp @@ -0,0 +1,157 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Types.hpp" +#include + +namespace Brisk::TextEngine { + +constexpr inline unsigned kHorizontalOversampling = 16; + +struct FontDef; + +namespace detail { +class FontInstance; + +inline std::filesystem::path findFontsDirectory() { + std::error_code ec; + const std::filesystem::path cwd = std::filesystem::current_path(ec); + if (!ec) { + const std::array candidates = { + cwd / "fonts", + cwd / "resources" / "fonts", + cwd / ".." / "fonts", + cwd / ".." / "resources" / "fonts", + cwd / ".." / ".." / "fonts", + cwd / ".." / ".." / "resources" / "fonts", + }; + for (const std::filesystem::path& candidate : candidates) { + ec.clear(); + if (std::filesystem::is_directory(candidate, ec)) { + return candidate; + } + } + } + return "fonts"; +} +} // namespace detail + +/** + * @brief Shared identity and ownership of one variation, pixel size, and load policy. + * + * Copies are cheap reference-counted handles. Font resources remain alive while any + * handle, including one stored by PreparedDocument, refers to the instance. + */ +using FontHandle = std::shared_ptr; + +/** + * @brief Opaque handles to the currently active FreeType and HarfBuzz font objects. + * + * Returned by activate() after the font's size has been made current. The pointers + * are owned by the FontDatabase and are only valid until the next activate() call. + */ +struct ActiveFont { + /// Opaque pointer to the active FreeType face (FT_Face). + void* ftFace{}; + /// Opaque pointer to the active HarfBuzz font (hb_font_t), sized to match ftFace. + void* hbFont{}; + /// FreeType load flags that shaping/rasterization should pass to FT_Load_Glyph. + int32_t loadFlags{}; + /// True when every printable ASCII codepoint (U+0020..U+007E) is present. + bool asciiRangePresent{}; + /// Stable process-wide identity of the font instance, for rasterization caches. + uint64_t instanceId{}; +}; + +/** + * @brief Counters describing the current state and lifetime activity of the font cache. + */ +struct FontCacheStats { + /// Number of distinct font faces currently cached. + size_t faceCount{}; + /// Number of FT_Size objects (pixel sizes) currently cached across all faces. + size_t sizeCount{}; + /// Number of times a cached size had to be (re)initialized for a new pixel size. + size_t sizeInitializations{}; + /// Number of hb_font_t objects currently cached. + size_t harfBuzzFontCount{}; + /// Number of times FT_New_Face was invoked (including scans and probes). + size_t ftNewFaceCalls{}; + /// Number of times FT_Done_Face was invoked. + size_t ftDoneFaceCalls{}; +}; + +/** + * @brief Abstract interface for resolving, activating, and rasterizing fonts. + * + * The database and layout pipeline are single-threaded. Implementations cache faces, + * pixel sizes, and HarfBuzz fonts internally; activate() centralizes FT_Size activation + * and guarantees that the returned hb_font_t's size is active. + */ +class FontDatabase { +public: + /// Destroys the database. All outstanding FontHandles keep their resources alive. + virtual ~FontDatabase() = default; + + /// Resolves a font from a structured font definition. + /// @param fontDef The family, style, weight, size, and variations to match. + /// @return A handle to the resolved font instance, or a null handle if no match. + [[nodiscard]] virtual FontHandle resolveFont(const FontDef& fontDef) const = 0; + + /// Resolves a font by its individual attributes. + /// @param familyName Case-insensitive family name (aliases are honored). + /// @param style Requested font style (normal, italic, ...). + /// @param weight Requested font weight; the closest available match is used. + /// @param fontSize Requested size in layout units. + /// @param variations Optional list of variation axis settings for variable fonts. + /// @param hinting Glyph hinting policy; affects the FreeType load flags used for + /// shaping and rasterization. + /// @return A handle to the resolved font instance, or a null handle if no match. + [[nodiscard]] virtual FontHandle resolveFont(std::string_view familyName, FontStyle style, + FontWeight weight, LayoutUnit fontSize, + std::span variations = {}, + Hinting hinting = Hinting::Auto) const = 0; + + /// Makes the font's pixel size current for shaping and rasterization. + /// @param fontHandle A handle previously returned by resolveFont(). + /// @return Opaque FreeType/HarfBuzz pointers valid until the next activate() call. + [[nodiscard]] virtual ActiveFont activate(const FontHandle& fontHandle) const = 0; + + /// Returns a snapshot of the cache counters for this database. + [[nodiscard]] virtual FontCacheStats cacheStats() const noexcept = 0; + + /// Registers every face of a font file (TTF/OTF/TTC) directly from borrowed memory. + /// The buffer is NOT copied: faces reference it in place, so the caller must keep + /// the bytes alive and unmodified for the lifetime of the database (and of any + /// FontHandle resolved from it). + /// @param data Borrowed font file bytes; must remain alive and unmodified for the + /// lifetime of the database and any FontHandle resolved from it. + /// @return The family names of the registered faces, in face order (a collection + /// file yields one entry per face); empty if the data is not a readable + /// font file. + [[nodiscard]] virtual std::vector registerFont(std::span data) = 0; + + /// Registers an alias that resolves to an existing family name. Family matching + /// is case-insensitive. A later alias may rebind the same name. + /// @param existingFamily An already-registered family name the alias resolves to. + /// @param alias The alias name to register. + /// @return False if existingFamily is not registered (the alias is then not + /// stored); true otherwise. + [[nodiscard]] virtual bool addAlias(std::string_view existingFamily, std::string_view alias) = 0; + + /// Scans a directory for font files (TTF/OTF/TTC) and registers every face found. + /// Non-recursive: only regular files directly inside the directory are considered. + /// Files that cannot be opened as fonts are silently skipped. Previously registered + /// fonts remain registered; the combined set is re-sorted by family name afterwards. + /// @param directory The directory to scan; a non-directory path is ignored. + virtual void scanDirectory(const std::filesystem::path& directory) = 0; +}; +} // namespace Brisk::TextEngine diff --git a/src/graphics/TextEngine/include/text_layout/Layout.hpp b/src/graphics/TextEngine/include/text_layout/Layout.hpp new file mode 100644 index 00000000..cfd4e5ca --- /dev/null +++ b/src/graphics/TextEngine/include/text_layout/Layout.hpp @@ -0,0 +1,938 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "BreakIterator.hpp" +#include "Types.hpp" +#include "FontDatabase.hpp" +#include + +namespace Brisk::TextEngine { + +// Non-owning view over a boundary table of the form: +// [0, b_1, b_2, ..., b_{n-1}, total] +// describing n half-open entities: [boundaries[i], boundaries[i+1]). +// +// Backing storage (e.g. arena-allocated array) must outlive this view. +struct BoundaryTable { + std::span boundaries; // size == count() + 1, size >= 1 + + explicit BoundaryTable(std::span boundaries) : boundaries(boundaries) {} + + [[nodiscard]] bool correct() const noexcept { + if (boundaries.empty() || boundaries.front() != 0) { + return false; + } + // An empty document still has one font slot, represented by the single + // zero-length range [0, 0). No offset lookup is performed for it because + // there are no graphemes to resolve. + if (boundaries.size() == 2 && boundaries[1] == 0) { + return true; + } + for (size_t i = 1; i < boundaries.size(); ++i) { + if (boundaries[i] <= boundaries[i - 1]) { + return false; + } + } + return true; + } + + // Number of entities described by this table. + [[nodiscard]] uint32_t count() const noexcept { + return boundaries.empty() ? 0 : static_cast(boundaries.size() - 1); + } + + // O(1) lookup: entity -> half-open offset range. + [[nodiscard]] Range rangeOf(uint32_t index) const noexcept { + assert(index < count()); + return Range{ boundaries[index], boundaries[index + 1] }; + } + + [[nodiscard]] uint32_t total() const noexcept { + return boundaries.empty() ? 0 : boundaries.back(); + } + + [[nodiscard]] bool empty() const noexcept { + return count() == 0; + } + + [[nodiscard]] uint32_t begin(uint32_t index) const noexcept { + assert(index < count()); + return boundaries[index]; + } + + [[nodiscard]] uint32_t end(uint32_t index) const noexcept { + assert(index < count()); + return boundaries[index + 1]; + } + + // O(log n) lookup: offset -> entity index such that + // boundaries[i] <= offset < boundaries[i+1]. + // Precondition: offset < boundaries.back() (i.e. offset is within range). + [[nodiscard]] uint32_t indexOf(uint32_t offset) const noexcept { + assert(!boundaries.empty()); + assert(offset < total()); + + const auto it = std::upper_bound(boundaries.begin(), boundaries.end(), offset); + return static_cast(it - boundaries.begin()) - 1; + } +}; + +/** + * @brief Bidirectional mapping between codepoint indices and grapheme cluster indices. + * + * Maintains two prefix tables over the input text: + * - `codepointToGrapheme`: for each codepoint offset, the index of the grapheme + * cluster containing it (size == codepoint count + 1, last entry == grapheme count). + * - `graphemeToCodepoint`: for each grapheme index, the starting codepoint offset + * of that cluster (size == grapheme count + 1, last entry == total codepoints). + * + * Example for text "áb" (U+0061 U+0301 U+0062): + * - codepointToGrapheme = [0, 0, 1] + * - graphemeToCodepoint = [0, 2, 3] + */ +struct GraphemeMap { + + /// @brief Constructs an empty map with a terminating entry in the grapheme table. + GraphemeMap() { + graphemeToCodepoint.push_back(0); + codepointToGrapheme.push_back(0); + } + + /** + * @brief Constructs the map by segmenting @p text into extended grapheme clusters. + * + * @param text UTF-32 text to segment. + */ + explicit GraphemeMap(std::u32string_view text) { + GraphemeBreakIterator iterator; + iterator.setText(text); + uint32_t prev = iterator.nextBreak(); // first break is always 0 + assert(prev == 0); + for (uint32_t boundary = iterator.nextBreak(); boundary != UINT32_MAX; + boundary = iterator.nextBreak()) { + graphemeToCodepoint.push_back(prev); + codepointToGrapheme.insert(codepointToGrapheme.end(), boundary - prev, + static_cast(graphemeToCodepoint.size() - 1)); + prev = boundary; + } + graphemeToCodepoint.push_back(prev); // terminating entry == total codepoints + codepointToGrapheme.push_back(static_cast(graphemeToCodepoint.size() - 1)); + } + + /** + * @brief Constructs a map covering @p range of an existing document-wide map. + * + * The resulting map is re-indexed so that grapheme 0 corresponds to the first + * grapheme cluster of @p range, without re-running the grapheme break iterator. + * + * @param document Document-wide grapheme map. + * @param range Grapheme range within @p document to slice. + */ + GraphemeMap(const GraphemeMap& document, GraphemeRange range) { + assert(range.min <= range.max); + assert(range.max <= document.graphemeCount()); + graphemeToCodepoint.push_back(0); + for (GraphemeIndex g = range.min; g < range.max; ++g) { + const CodepointRange codepointRange = document.codepointRangeForGrapheme(g); + graphemeToCodepoint.push_back(graphemeToCodepoint.back() + codepointRange.distance()); + codepointToGrapheme.insert(codepointToGrapheme.end(), codepointRange.distance(), + static_cast(graphemeToCodepoint.size() - 2)); + } + codepointToGrapheme.push_back(static_cast(graphemeToCodepoint.size() - 1)); + } + + /// @brief Returns the number of extended grapheme clusters in the mapped text. + uint32_t graphemeCount() const noexcept { + return static_cast(graphemeToCodepoint.size() - 1); + } + + /// @brief Returns the number of codepoints in the mapped text. + uint32_t codepointCount() const noexcept { + return static_cast(codepointToGrapheme.size() - 1); + } + + /// Returns the document-relative codepoint offset of every grapheme boundary. + /// The returned view remains valid while this map is alive and is not modified. + [[nodiscard]] std::span graphemeBoundaries() const noexcept { + return graphemeToCodepoint; + } + + /** + * @brief Returns the starting codepoint offset of grapheme cluster @p g. + * @pre @p g <= graphemeCount(). + */ + CodepointIndex toCodepoint(GraphemeIndex g) const noexcept { + assert(g < graphemeToCodepoint.size()); + return graphemeToCodepoint[g]; + } + + /** + * @brief Returns the index of the grapheme cluster containing codepoint @p cp. + * @pre @p cp <= codepointCount(). + */ + GraphemeIndex toGrapheme(CodepointIndex cp) const noexcept { + assert(cp < codepointToGrapheme.size()); + return codepointToGrapheme[cp]; + } + + /** + * @brief Returns the half-open codepoint range covered by grapheme cluster @p g. + * @pre @p g < graphemeCount(). + */ + CodepointRange codepointRangeForGrapheme(GraphemeIndex g) const noexcept { + assert(g < graphemeToCodepoint.size() - 1); + return CodepointRange{ graphemeToCodepoint[g], graphemeToCodepoint[g + 1] }; + } + + /** + * @brief Converts a document-relative GraphemeRange to the exact half-open CodepointRange it covers. + * @pre @p range.min <= range.max && range.max <= graphemeCount(). + */ + [[nodiscard]] CodepointRange toCodepointRange(GraphemeRange range) const noexcept { + assert(range.min <= range.max); + assert(range.max <= graphemeCount()); + return CodepointRange{ toCodepoint(range.min), toCodepoint(range.max) }; + } + + /** + * @brief Converts a document-relative CodepointRange to the tightest enclosing GraphemeRange. + * + * If range is empty (min == max), returns an empty GraphemeRange at toGrapheme(min). + * Otherwise spans from the cluster containing range.min through the cluster containing range.max - 1. + * + * @pre @p range.min <= range.max && range.max <= codepointCount(). + */ + [[nodiscard]] GraphemeRange toGraphemeRange(CodepointRange range) const noexcept { + assert(range.min <= range.max); + assert(range.max <= codepointCount()); + if (range.empty()) { + const GraphemeIndex g = toGrapheme(range.min); + return GraphemeRange{ g, g }; + } + return GraphemeRange{ toGrapheme(range.min), toGrapheme(range.max - 1) + 1 }; + } + + /** + * @brief Iterates over extended grapheme cluster ranges covered by the specified @p range. + */ + template + requires std::invocable + void iterate(GraphemeRange range, Callback&& onGrapheme) const { + assert(range.min <= range.max); + assert(range.max <= graphemeCount()); + for (GraphemeIndex g = range.min; g < range.max; ++g) { + onGrapheme(codepointRangeForGrapheme(g)); + } + } + + /** + * @brief Iterates over all extended grapheme cluster ranges in the map. + */ + template + requires std::invocable + void iterate(Callback&& onGrapheme) const { + iterate(GraphemeRange{ 0, graphemeCount() }, std::forward(onGrapheme)); + } + + /** + * @brief Returns a view of this map as a boundary table over grapheme clusters. + * + * Entity @c i covers the half-open codepoint range of grapheme cluster @c i, i.e. + * `[toCodepoint(i), toCodepoint(i + 1))`. + * @pre The map must outlive the returned table. + */ + [[nodiscard]] BoundaryTable boundaryTable() const noexcept { + return BoundaryTable{ graphemeToCodepoint }; + } + +private: + std::vector + codepointToGrapheme; // .size() == {codepoints count} + 1, last entry == {grapheme count} + std::vector + graphemeToCodepoint; // .size() == {grapheme count} + 1, last entry == {total codepoints} + // Example for text "áb" (U+0061 U+0301 U+0062): + // codepointToGrapheme = [0, 0, 1] + // graphemeToCodepoint = [0, 2, 3] +}; + +/** + * @brief Complete immutable document text and document-wide analysis tables. + */ +struct DocumentSource { + std::u32string_view text; + GraphemeMap graphemes; + + DocumentSource() = default; + + explicit DocumentSource(std::u32string_view text) : text(text), graphemes(text) {} + + [[nodiscard]] uint32_t codepointCount() const noexcept { + return static_cast(text.size()); + } + + [[nodiscard]] uint32_t graphemeCount() const noexcept { + return graphemes.graphemeCount(); + } + + [[nodiscard]] std::u32string_view substr(CodepointRange range) const noexcept { + assert(range.min <= range.max); + assert(range.max <= text.size()); + return text.substr(range.min, range.distance()); + } + + [[nodiscard]] CodepointRange clamp(CodepointRange range) const noexcept { + range.min = std::min(range.min, static_cast(text.size())); + range.max = std::clamp(range.max, range.min, static_cast(text.size())); + return range; + } +}; + +VerticalMetrics getVerticalMetrics(const FontDatabase* fontDatabase, const FontHandle& fontHandle); + +/** + * @brief Returns additional scaled metrics for decoration and common glyph dimensions. + * + * Returns zeroed metrics when @p fontDatabase or @p fontHandle is null, or when the + * font cannot be activated. Metrics unavailable in a font's OpenType tables remain zero. + */ +ExtendedMetrics getExtendedMetrics(const FontDatabase* fontDatabase, const FontHandle& fontHandle); + +std::shared_ptr getDefaultFontDatabase(); + +/** @brief Creates an independently owned, empty database. Use FontDatabase::scanDirectory() + * to register fonts from a directory. */ +[[nodiscard]] std::shared_ptr createFontDatabase(); + +/** + * @brief Creates a database using an existing shared FreeType library. + * + * `library` is the `FT_Library` value erased as `void*`; `owner` keeps the + * library alive until the database and its font handles are destroyed. + */ +[[nodiscard]] std::shared_ptr createFontDatabase(std::shared_ptr owner, void* library); + +// --------------------------------------------------------------------------- +// Step 1: Paragraph segmentation +// --------------------------------------------------------------------------- + +/** + * @brief Identifies the codepoint sequence following and terminating a paragraph. + */ +enum class ParagraphSeparatorKind : uint8_t { + None, ///< No following paragraph separator. + LineFeed, ///< Line feed (LF, U+000A). + CarriageReturn, ///< Lone carriage return (CR, U+000D). + CarriageReturnLineFeed, ///< CRLF (U+000D U+000A), treated as one separator. + InformationSeparator4, ///< File Separator / Information Separator Four (FS / IS4, U+001C). + InformationSeparator3, ///< Group Separator / Information Separator Three (GS / IS3, U+001D). + InformationSeparator2, ///< Record Separator / Information Separator Two (RS / IS2, U+001E). + NextLine, ///< Next Line (NEL, U+0085). + ParagraphSeparator, ///< Unicode paragraph separator (PS, U+2029). +}; + +/** + * @brief Returns the length in codepoints of the given paragraph separator kind. + * + * @param kind The paragraph separator kind. + * @return 0 for `None`, 2 for `CarriageReturnLineFeed`, and 1 for all other separator kinds. + */ +uint32_t paragraphSeparatorKindCodepoints(ParagraphSeparatorKind kind); + +/** + * @brief Segments a range of input text into independent BiDi paragraphs in a streaming manner. + * + * Recognized separators are LF, lone CR, CRLF, IS4..IS2 (U+001C..U+001E), NEL (U+0085), + * and PS (U+2029). CRLF is recognized atomically. Leading and consecutive separators + * produce empty paragraphs. A trailing separator produces an additional final empty paragraph + * with no separator, and empty input produces one such paragraph. + * + * For each detected paragraph, @p onParagraph is invoked with: + * - A `CodepointRange` spanning the entire paragraph, including any trailing separator codepoints. + * - The `ParagraphSeparatorKind` identifying the trailing separator (or `None` for the final paragraph + * if it has no separator, or for the trailing empty paragraph). + * + * @param document Complete UTF-32 document source and its document-wide grapheme map. + * @param range Codepoint range within @p document to segment. + * @param onParagraph Callback invoked in source order for each detected paragraph. + */ +void segmentParagraphs(const DocumentSource& document, CodepointRange range, + function_ref onParagraph); + +// --------------------------------------------------------------------------- +// Step 2: BiDi resolution +// --------------------------------------------------------------------------- + +/** + * @brief Resolves bidirectional (BiDi) embedding levels for a single paragraph in a streaming manner. + * + * Runs are reported in source (logical) order with document-relative codepoint ranges. + * + * @param document Complete UTF-32 document source and its document-wide grapheme map. + * @param paragraphRange Document-relative codepoint range of the single paragraph to resolve. + * @param baseDirection Base direction policy for resolving the paragraph's embedding level. + * @param onRun Callback invoked for each unidirectional BiDi run with `(CodepointRange range, BiDiLevel + * level)`. + * @return The resolved paragraph base direction (`Direction::LeftToRight` or `Direction::RightToLeft`). + */ +Direction resolveBidi(const DocumentSource& document, CodepointRange paragraphRange, + BaseDirection baseDirection, function_ref onRun); + +// --------------------------------------------------------------------------- +// Step 3: Script itemization +// --------------------------------------------------------------------------- + +using ScriptTag = uint32_t; + +/** + * @brief Resolves the Unicode script for each extended grapheme cluster in a paragraph. + * + * For empty input, returns an empty vector. + * + * @param document Complete UTF-32 document source and its document-wide grapheme map. + * @param paragraphRange Document-relative codepoint range of the paragraph. + * @param out Output span receiving one ISO 15924 script tag per extended grapheme cluster in source order. + */ +void detectScripts(const DocumentSource& document, CodepointRange paragraphRange, std::span out); + +/** + * @brief Represents a single script-itemized run within a BiDi run. + */ +struct ScriptRun { + BiDiLevel level; ///< Resolved BiDi embedding level. + ScriptTag script; ///< ISO 15924 four-character script tag. +}; + +/** + * @brief Itemizes a single BiDi run into script runs in a streaming manner. + * + * For empty input, no callbacks are invoked. + * Emitted ranges are document-relative subsets of @p bidiRange. + * + * @param document Complete UTF-32 document source and its document-wide grapheme map. + * @param bidiRange Document-relative codepoint range of the single BiDi run to itemize. + * @param level BiDi embedding level of this run. + * @param script Script tag override (zero for automatic script detection). + * @param runScripts Pre-detected scripts per grapheme cluster for this BiDi run. + * @param onRun Callback invoked in source order for each itemized `ScriptRun`. + */ +void itemizeScripts(const DocumentSource& document, CodepointRange bidiRange, BiDiLevel level, + ScriptTag script, std::span runScripts, + function_ref onRun); + +// --------------------------------------------------------------------------- +// Step 4: Font resolving +// --------------------------------------------------------------------------- + +/** + * @brief Represents a single resolved font run ready for shaping. + */ +struct ResolvedFont { + FontHandle fontHandle; ///< Resolved concrete font handle (empty if no font found). + FontRunIndex fontRunIndex; ///< Source FontDef index in fonts list. +}; + +/** + * @brief Resolves fonts for a script run in a streaming manner. + * + * Emits document-relative codepoint ranges for merged shapeable runs and control runs. + * + * @param document Complete UTF-32 document source and its document-wide grapheme map. + * @param scriptRange Document-relative codepoint range of the script run. + * @param run The script run metadata. + * @param fontDatabase The font database used to open fonts (may be null). + * @param fonts Span of font definitions. + * @param fontBoundaries Boundary table mapping document codepoint offsets to font definition indices. + * @param onShapeableRun Callback invoked for each merged shapeable font run. + * @param onControlRun Callback invoked for each single control code run. + */ +void resolveFonts( + const DocumentSource& document, CodepointRange scriptRange, const ScriptRun& run, + const FontDatabase* fontDatabase, std::span fonts, BoundaryTable fontBoundaries, + function_ref onShapeableRun, + function_ref onControlRun); + +// --------------------------------------------------------------------------- +// Step 5: Shaping runs +// --------------------------------------------------------------------------- + +/** + * @brief Represents a single shaped glyph produced by HarfBuzz. + */ +struct Glyph { + uint32_t glyphId; ///< Font-specific glyph ID. + GraphemeIndex graphemeIndex; ///< Source grapheme that produced this glyph (relative to run graphemes). + LayoutUnit xAdvance; ///< The horizontal advance of the glyph. + LayoutUnit xOffset; ///< The horizontal offset of the glyph. + LayoutUnit yOffset; ///< The vertical offset of the glyph. +}; + +constexpr inline size_t maximumOpenTypeFeatures = 16; + +/** + * @brief Shapes a single font-resolved run with HarfBuzz in a streaming manner. + * + * @param document Complete UTF-32 document source and its document-wide grapheme map. + * @param textContextRange Context range for determining BeginOfText/EndOfText flags. + * @param runRange Document-relative codepoint range of the font-resolved run to shape. + * @param fontDatabase Font database (may be null). + * @param run The font-resolved run metadata. + * @param level BiDi embedding level of the run. + * @param script Resolved script code. + * @param fontDef Font definition. + * @param language Optional BCP-47 language tag. + * @param onGlyph Callback invoked for each shaped glyph. + */ +void shapeRun(const DocumentSource& document, CodepointRange textContextRange, CodepointRange runRange, + const FontDatabase* fontDatabase, const ResolvedFont& run, BiDiLevel level, ScriptTag script, + const FontDef& fontDef, std::string_view language, + function_ref onGlyph); + +// --------------------------------------------------------------------------- +// Step 6: Line breaking +// --------------------------------------------------------------------------- + +/** + * @brief Describes how a laid-out line ended. + */ +enum class LineEndKind : uint8_t { + SoftWrap, ///< Line ended at an automatically selected wrap point. + MandatoryBreak, ///< Line consumed an intra-paragraph mandatory break. + ParagraphEnd, ///< Line ended at a paragraph separator. +}; + +/** + * @brief Classifies a codepoint's line break opportunity (UAX #14 style). + * + * Any value other than the enumerators below is treated as "no break opportunity". + */ +enum class LineBreakKind : uint8_t { + MustBreak = 0, ///< A mandatory break is required after this codepoint. + AllowBreak = 1, ///< A soft wrap is permitted after this codepoint. + NoBreak = 2, ///< No line break is permitted after this codepoint. + InsideCharacter = 3, ///< The position is inside a legacy encoded character. +}; + +/** + * @brief Computes line break opportunities for a codepoint range in text. + * + * Fills @p out with one `LineBreakKind` per codepoint in @p range. + * `out.size()` must equal `range.distance()`. + * + * @param text Complete UTF-32 document text. + * @param range Document-relative codepoint range to analyze. + * @param out Output span receiving the break kind for each codepoint. + * @param language Optional BCP-47 language tag. + */ +void breakOpportunities(std::u32string_view text, CodepointRange range, std::span out, + const char* language); + +/** + * @brief Computes line break opportunities for the given standalone text. + */ +inline void breakOpportunities(std::u32string_view paragraphText, std::span out, + const char* language) { + breakOpportunities(paragraphText, CodepointRange{ 0, static_cast(paragraphText.size()) }, + out, language); +} + +// --------------------------------------------------------------------------- +// Prepared paragraph -- cacheable output of analysis and shaping +// --------------------------------------------------------------------------- + +/** @brief Per-grapheme flags consumed by concrete line layout. */ +struct GraphemeFlags { + /// Whether this grapheme cluster consists of trimmable whitespace (e.g. trailing space before wrap). + bool trimmableWhitespace : 1 { false }; + /// Whether this grapheme cluster represents a tab character (`\t`) subject to tab-stop expansion. + bool isTab : 1 { false }; + /// Whether this grapheme cluster is a separator between paragraphs. + bool isParagraphSeparator : 1 { false }; + /// Whether HarfBuzz marked the boundary after this grapheme unsafe for line breaking. + bool unsafeToBreakAfter : 1 { false }; +}; + +static_assert(sizeof(GraphemeFlags) == sizeof(std::uint8_t)); + +/** + * @brief One font-resolved shaped run in prepared text. + * + * In a `PreparedDocument`, ranges are document-relative. A control run may have an empty glyph range while + * still contributing grapheme advance. + */ +struct GlyphRun { + /// Codepoint range covered by this run, relative to the owning prepared object. + CodepointRange codepointRange; + /// Grapheme cluster range covered by this run, relative to the owning prepared object. + GraphemeRange graphemeRange; + /// Glyph range covered by this run in the owning prepared object's `glyphs` array. + GlyphRange glyphRange; + /// Concrete font handle resolved for shaping this run (empty for unresolved or control runs). + FontHandle fontHandle{}; + /// Index into the input font definitions span (`fonts`). + FontRunIndex fontRunIndex{ 0 }; + /// Resolved BiDi embedding level of this run. + BiDiLevel level{ 0 }; + /// Resolved ISO 15924 four-character script tag of this run. + ScriptTag script{ 0 }; + /// Vertical baseline offset for this run; positive values move the glyphs upward. + LayoutUnit verticalAlign{ kZero }; + /// Scaled font vertical metrics (ascent, descent, lineGap/leading). + VerticalMetrics metrics{}; +}; + +constexpr inline CodepointRange entireText{ 0, UINT32_MAX }; + +// --------------------------------------------------------------------------- +// Step 7: Layout output +// --------------------------------------------------------------------------- + +/** + * @brief One directly renderable glyph-run slice placed on a concrete line. + * + * Indices refer to the owning prepared object's flat glyph/run arrays. They are paragraph-relative + * for the legacy paragraph API and document-relative for the document API. + */ +struct LayoutGlyphRun { + /// Index into the owning prepared object's `glyphRuns` array. + RunIndex preparedRunIndex{ 0 }; + /// Glyph range in the owning prepared object's `glyphs` array. + GlyphRange glyphRange; + /// Horizontal origin in the associated layout's coordinates (added to glyph run-local positions). + LayoutUnit xOffset{ kZero }; + /// Baseline vertical position in the associated layout's coordinates. + LayoutUnit yOffset{ kZero }; +}; + +/** + * @brief Final geometry and source coverage of one visible line. + * + * Source ranges use the coordinate system of the associated prepared object. They are + * document-relative in `DocumentLayout`. A paragraph separator is not prepared as paragraph + * content; an intra-paragraph mandatory line break may + * still be included in the line that consumes it. + */ +struct LayoutLine { + /// Logical source coverage in grapheme clusters, relative to the owning prepared object. + GraphemeRange graphemeRange; + /// Logical source coverage in codepoints, relative to the owning prepared object. + CodepointRange codepointRange; + /// Base direction of the paragraph containing this line. + Direction baseDirection{ Direction::LeftToRight }; + /// Range in the associated layout's `glyphRuns`, ordered visually from left to right. + RunRange glyphRunRange; + /// How the line ended (soft wrap, mandatory break, or paragraph end). + LineEndKind endKind; + /// Intrinsic inline origin in layout coordinates. The selected alignment edge is at x = 0; + /// maxLineWidth is used only for wrapping. + LayoutUnit originX; + /// Baseline in layout coordinates. + LayoutUnit baselineY; + /// Distance above the baseline. + LayoutUnit ascent; + /// Signed distance to the descender below the baseline; negative values point downward. + LayoutUnit descent; + /// Additional inter-line spacing. + LayoutUnit leading; + /// Full laid-out width, including trailing spacing. + LayoutUnit width; + /// Width used for alignment, excluding trimmable trailing spacing. + LayoutUnit trimmedWidth; +}; + +/** + * @brief Cacheable output of analysis, font resolution, and shaping for a complete document. + * + * The preparation buffers are document-wide and use document-relative indices. Paragraph + * metadata identifies the portions of those flat buffers belonging to each paragraph. + */ +struct PreparedDocument { + struct Paragraph { + /// Document-relative content range, excluding the trailing paragraph separator. + CodepointRange paragraphRange{ 0, 0 }; + /// Resolved base embedding direction for this paragraph. + Direction baseDirection{ Direction::LeftToRight }; + /// Separator following this paragraph, if any. + ParagraphSeparatorKind paragraphSeparatorKind{ ParagraphSeparatorKind::None }; + /// Range in the document-wide grapheme and prepared-grapheme arrays. Separator graphemes + /// remain globally indexed but are not part of paragraph content. + GraphemeRange graphemeRange{ 0, 0 }; + /// Range in the document-wide glyph array. + GlyphRange glyphRange{ 0, 0 }; + /// Range in the document-wide glyph-run array. + RunRange glyphRunRange{ 0, 0 }; + /// Font metrics used to give an empty paragraph a visible line and caret. + VerticalMetrics emptyLineMetrics{}; + }; + + /// Owns the font database instance needed for later glyph rasterization. + std::shared_ptr fontDatabase; + /// Unique identifier copied into the corresponding document layout. + uint64_t cookie{ 0 }; + /// Document-wide grapheme-to-codepoint and codepoint-to-grapheme mapping table. + GraphemeMap graphemeMap; + /// One entry for every segmented paragraph, in source order. + std::vector paragraphs; + /// Line break opportunity per document codepoint. + std::vector breakOpportunities; + /// Per-grapheme layout data, indexed by document grapheme offset. + std::vector graphemes; + /// Document-wide prefix sum of grapheme advances; size is `graphemes.size() + 1`. Separator + /// graphemes have default data and therefore contribute no advance. + std::vector graphemeAdvancePrefix; + /// All shaped glyphs in document order. + std::vector glyphs; + /// All font-resolved and shaped runs in document order. + std::vector glyphRuns; +}; + +/** + * @brief Performs analysis and shaping for the complete document. + * + * The document is segmented into paragraphs. Each paragraph range excludes its trailing + * separator, while `PreparedDocument::Paragraph::paragraphSeparatorKind` records the separator. + * Separator graphemes remain in the global `graphemeMap` and corresponding flat-array positions; + * their `GraphemeFlags` entries are default-constructed and are not layout content. All flat + * arrays and `GlyphRun` ranges use document-relative indices. + * + * An empty @p baseDirections span uses `BaseDirection::DefaultLTR` for every paragraph. A + * one-element span broadcasts its value to every paragraph. Otherwise it must contain one value + * per prepared paragraph. + * + * @param document Complete UTF-32 document and document-wide grapheme map. + * @param fontDatabase Font database retained for later rasterization. + * @param fonts Complete span of font definitions. + * @param fontBoundaries Document-relative font boundary table. + * @param baseDirections Empty, one-element, or one-per-paragraph direction policies. + * @param script Explicit script override, or zero for automatic detection. + * @param language Optional BCP-47 language tag. + * @pre `fontBoundaries.correct()` and `fontBoundaries.count() == fonts.size()`. + * @pre `baseDirections` is empty, has one element, or has one element per prepared paragraph. + */ +[[nodiscard]] PreparedDocument prepareDocument(const DocumentSource& document, + std::shared_ptr fontDatabase, + std::span fonts, + BoundaryTable fontBoundaries, + std::span baseDirections = {}, + ScriptTag script = 0, std::string_view language = {}); + +/** + * @brief Repeating tab stops measured from each paragraph's base-direction margin. + * + * Stops are at `firstStop + N * interval` for non-negative integers @c N. An interval of zero + * disables tab expansion, leaving tab characters with zero advance. + */ +struct TabStops { + LayoutUnit firstStop{ kZero }; + LayoutUnit interval{ kZero }; +}; + +/** + * @brief Complete document layout produced by the text layout pipeline. + * + * Lines are ordered by paragraph and source order. Their source ranges are document-relative, + * and their glyph-run ranges index the document-wide `glyphRuns` array. Vertical coordinates are + * continuous across paragraphs. Every paragraph contributes at least one `ParagraphEnd` line, + * including empty paragraphs. + */ +struct DocumentLayout { + /// Copy of `PreparedDocument::cookie` from the document this layout was produced from. + uint64_t cookie{ 0 }; + /// Lines in document/source order. Source ranges and indices are document-relative. + std::vector lines; + /// Directly renderable run slices in per-line visual order. + std::vector glyphRuns; + /// Union of vertical line bounds in document layout coordinates. + LayoutRange verticalTextBounds{ kZero, kZero }; + /// Union of horizontal line bounds in document layout coordinates. + LayoutRange horizontalTextBounds{ kZero, kZero }; + /// Union of horizontal trimmed line bounds in document layout coordinates. + LayoutRange horizontalTrimmedTextBounds{ kZero, kZero }; +}; + +/** + * @brief Greedily lays out a complete prepared document. + * + * Paragraphs are processed sequentially without changing the document-relative indices in the + * prepared buffers. A paragraph's text indent applies only to its first line. Empty @p alignments + * use `TextAlignment::Start`, and empty @p textIndents use `kZero`. A one-element span broadcasts + * its value to every paragraph. Otherwise each span must contain one value per paragraph. + * `maxLineWidth` and @p tabStops apply to every paragraph. When `maxLineWidth` is `kInfinity`, + * optional line-break opportunities are ignored: each paragraph is laid out on one line, except + * that mandatory breaks still end the current line. Tab stops and first-line indents remain active. + * + * @param document Complete prepared document. + * @param alignments Empty, one-element, or one-per-paragraph alignment values. + * @param maxLineWidth Maximum width used for every paragraph; use `kInfinity` or `kZero` to + * disable wrapping while continuing to honor mandatory line breaks. Horizontal coordinates are + * intrinsic and independent of this value: left/start-LTR/end-RTL lines start at x = 0, centered + * lines straddle x = 0, and right/end-LTR/start-RTL lines end at x = 0. If @p allowBreakAnywhere + * is true, wrapping is also allowed between adjacent grapheme clusters where HarfBuzz does not + * report an unsafe break. + * @param tabStops Repeating tab-stop configuration used for every paragraph. + * @param textIndents Empty, one-element, or one-per-paragraph first-line indents. + * @param allowBreakAnywhere Permit wrapping at any HarfBuzz-safe grapheme boundary. + * @pre `alignments` and `textIndents` are empty, contain one value, or contain one value per paragraph. + */ +[[nodiscard]] DocumentLayout layoutPreparedDocument(const PreparedDocument& document, + std::span alignments, + LayoutUnit maxLineWidth, TabStops tabStops = {}, + std::span textIndents = {}, + bool allowBreakAnywhere = false); + +// --------------------------------------------------------------------------- +// Caret & selection queries +// --------------------------------------------------------------------------- + +/** + * @brief Disambiguates a caret that sits at a boundary shared by two valid renderings. + * + * A `GraphemeIndex` alone is ambiguous exactly at the boundary between two BiDi runs of + * different level, and at a soft-wrap boundary where the same offset ends one line and begins + * the next. `Upstream` attaches the caret to the content immediately before the index (the run + * or line it terminates); `Downstream` attaches it to the content immediately after (the run or + * line it begins). Away from such boundaries, both values resolve to the same position. + */ +enum class CaretAffinity : uint8_t { + Upstream, ///< Attach to the content preceding the grapheme index. + Downstream, ///< Attach to the content following the grapheme index. +}; + +/** + * @brief A logical caret location: a grapheme boundary plus its boundary disambiguation. + */ +struct CaretIndex { + GraphemeIndex grapheme{ 0 }; + CaretAffinity affinity{ CaretAffinity::Downstream }; +}; + +/** + * @brief Single-point rendering geometry for a caret. + * + * Vertical extent is intentionally not duplicated here: use the returned line index to look up + * `baselineY`, `ascent`, and `descent` in the associated layout's `lines` array. + */ +struct CaretPosition { + LayoutUnit x{ kZero }; ///< Horizontal position in layout coordinates. + LineIndex line{ 0 }; ///< Index into the associated layout's `lines` array. + BiDiLevel level{ 0 }; ///< Embedding level of the run the caret is attached to. +}; + +/** + * @brief Computes a caret position using document-relative grapheme offsets. + * + * The returned line index refers to `DocumentLayout::lines`. At a paragraph boundary, upstream + * affinity attaches to the preceding paragraph and downstream affinity attaches to the following + * paragraph. Separator graphemes are not layout content; caret positions remain at adjacent + * paragraph boundaries. Empty paragraphs have a valid zero-width caret line. + * + * @param document Complete prepared document. + * @param layout Document layout produced from @p document. + * @param index Document-relative grapheme boundary and affinity. + * @pre `index.grapheme <= document.graphemeMap.graphemeCount()`. + * @pre `layout.cookie == document.cookie`. + */ +[[nodiscard]] CaretPosition caretPosition(const PreparedDocument& document, const DocumentLayout& layout, + CaretIndex index); + +/** + * @brief Hit-tests a document layout and returns a document-relative grapheme offset. + * + * The point is clamped to the nearest document line vertically and to the nearest caret boundary + * horizontally. Pass the returned index to `caretPosition` to obtain its geometry. + * + * @param document Complete prepared document. + * @param layout Document layout produced from @p document. + * @param x Horizontal layout coordinate. + * @param y Vertical layout coordinate. + * @pre `layout.cookie == document.cookie`. + * @return A document-relative grapheme boundary and its affinity. The hit-tested line is available + * by passing the result to `caretPosition`. + */ +[[nodiscard]] CaretIndex hitTestPoint(const PreparedDocument& document, const DocumentLayout& layout, + LayoutUnit x, LayoutUnit y); + +/** + * @brief One highlight rectangle contributed by a single visual run to a selection. + * + * A logically contiguous selection can require more than one rectangle on a single line when it + * crosses a BiDi run boundary, since selected text need not be visually contiguous. Affinity + * plays no role here: unlike a caret, a highlighted range is unambiguous regardless of which + * side of a boundary its endpoints are conceptually attached to. + */ +struct SelectionRect { + LineIndex line{ 0 }; ///< Index into the associated layout's `lines` array. + LayoutUnit x0{ kZero }; ///< Left edge in layout coordinates. + LayoutUnit x1{ kZero }; ///< Right edge in layout coordinates. +}; + +/** + * @brief Enumerates document-relative selection rectangles, including cross-paragraph selections. + * + * A selection may span paragraph separators. Rectangles are emitted only for rendered content, in + * visual order within each line and document line order overall. Separator and empty-paragraph + * positions do not produce rectangles. + * + * @param document Complete prepared document. + * @param layout Document layout produced from @p document. + * @param selection Document-relative grapheme range with an exclusive maximum. + * @param onRect Callback invoked once for each emitted rectangle. + * @pre `layout.cookie == document.cookie`. + */ +void selectionRects(const PreparedDocument& document, const DocumentLayout& layout, GraphemeRange selection, + function_ref onRect); + +/** + * @brief Metrics and dimensions of a rasterized glyph bitmap. + */ +struct RasterizedGlyph { + enum class Format : uint8_t { + Mask8, + BGRA8, + }; + + /// Horizontal offset of the bitmap's left edge from the glyph origin (pixels). + int left{}; + /// Vertical offset of the bitmap's top edge from the glyph origin (pixels, up is positive). + int top{}; + /// Width of the bitmap in pixels. + uint32_t width{}; + /// Height of the bitmap in pixels. + uint32_t height{}; + /// Number of bytes between rows in the FreeType bitmap (may be negative). + int pitch{}; + /// Pixel format of the borrowed bitmap buffer. + Format format = Format::Mask8; + /// Bytes between rows in the returned bitmap, normalized to positive stride. + uint32_t bytesPerPixel = 1; + /// Effective horizontal raster scale. Color/SVG glyphs may use native scale (1). + int horizontalScale = 1; +}; + +/** @brief Rendering policy applied while rasterizing a glyph. */ +struct RasterizationOptions { + /// Horizontal oversampling factor. The vertical size remains unchanged. + int horizontalScale = 1; + /// Permit color bitmap and SVG glyph output when available. + bool enableColor = false; +}; + +/// Rasterizes a glyph at the requested scale and invokes `callback` with its metrics +/// and the borrowed FreeType bitmap buffer. The buffer is valid only for the duration +/// of the callback and must not be retained or modified. +[[nodiscard]] bool rasterize(const ActiveFont& activeFont, const FontHandle& fontHandle, uint32_t glyphId, + const RasterizationOptions& options, + function_ref callback); + +inline bool rasterize(const ActiveFont& activeFont, const FontHandle& fontHandle, uint32_t glyphId, int scale, + function_ref callback) { + return rasterize(activeFont, fontHandle, glyphId, RasterizationOptions{ .horizontalScale = scale }, + std::move(callback)); +} + +} // namespace Brisk::TextEngine diff --git a/src/graphics/TextEngine/include/text_layout/Types.hpp b/src/graphics/TextEngine/include/text_layout/Types.hpp new file mode 100644 index 00000000..6cedbd8e --- /dev/null +++ b/src/graphics/TextEngine/include/text_layout/Types.hpp @@ -0,0 +1,185 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace Brisk::TextEngine { + +#ifdef LAYOUT_FLOAT +using LayoutUnit = float; + +constexpr inline LayoutUnit kZero = 0.0f; +constexpr inline LayoutUnit kInfinity = std::numeric_limits::max(); +constexpr inline LayoutUnit kLarge = 65536.f; + +constexpr LayoutUnit fromFloat(float value) { + return value; +} + +constexpr float toFloat(LayoutUnit value) { + return value; +} + +constexpr LayoutUnit from26Dot6(int32_t value) { + return static_cast(value) * (1.0f / 64.0f); +} + +constexpr int32_t to26Dot6(LayoutUnit value) { + return static_cast(value * 64.0f + 0.5f); +} + +constexpr LayoutUnit half(LayoutUnit value) { + return value * 0.5f; +} +#else +using LayoutUnit = int32_t; + +constexpr inline LayoutUnit kZero = 0; +constexpr inline LayoutUnit kInfinity = std::numeric_limits::max(); +constexpr inline LayoutUnit kLarge = 4194304; + +constexpr LayoutUnit fromFloat(float value) { + return static_cast(value >= 0.0f ? value * 64.0f + 0.5f : value * 64.0f - 0.5f); +} + +constexpr float toFloat(LayoutUnit value) { + return static_cast(value) * (1.0f / 64.0f); +} + +constexpr LayoutUnit from26Dot6(int32_t value) { + return value; +} + +constexpr int32_t to26Dot6(LayoutUnit value) { + return static_cast(value); +} + +constexpr LayoutUnit half(LayoutUnit value) { + return value / 2; +} +#endif + +using CodepointIndex = uint32_t; +using GraphemeIndex = uint32_t; +using GlyphIndex = uint32_t; +using RunIndex = uint32_t; +using LineIndex = uint32_t; +using ParagraphIndex = uint32_t; +using FontRunIndex = uint32_t; + +using CodepointRange = Range; +using GraphemeRange = Range; +using GlyphRange = Range; +using RunRange = Range; +using LayoutRange = Range; + +/// Converts a 4-character tag (e.g. "latn") to its big-endian uint32_t representation. +constexpr uint32_t fourCCToUint32(std::string_view cc) { + assert(cc.size() == 4); + return (static_cast(static_cast(cc[0])) << 24) | + (static_cast(static_cast(cc[1])) << 16) | + (static_cast(static_cast(cc[2])) << 8) | + static_cast(static_cast(cc[3])); +} + +struct OpenTypeFeatureFlag { + uint32_t feature; + bool enabled; +}; + +enum class FontStyle : uint8_t { + Normal = 0, + Italic = 1, +}; + +enum class FontWeight : uint16_t { + Weight100 = 100, + Weight200 = 200, + Weight300 = 300, + Weight400 = 400, + Weight500 = 500, + Weight600 = 600, + Weight700 = 700, + Weight800 = 800, + Weight900 = 900, + + Thin = Weight100, + ExtraLight = Weight200, + Light = Weight300, + Regular = Weight400, + Medium = Weight500, + SemiBold = Weight600, + Bold = Weight700, + ExtraBold = Weight800, + Black = Weight900, +}; + +enum class TextAlignment : uint8_t { + Start, + End, + Left, + Right, + Center, + Justify, +}; + +using OpenTypeTag = uint32_t; + +/// Glyph hinting policy applied when loading glyphs through FreeType. +enum class Hinting : uint8_t { + Auto, ///< Use the backend's default hinting behavior. + Enable, ///< Force hinting on. + Disable, ///< Load glyphs unhinted (FT_LOAD_NO_HINTING). +}; + +struct FontVariation { + OpenTypeTag axisTag; + LayoutUnit value; +}; + +/** + * @brief Represents font properties and settings for text rendering. + */ +struct FontDef { + std::span familyNames; + LayoutUnit fontSize; + FontStyle style; + FontWeight weight; + LayoutUnit lineHeight; // same unit as fontSize, 0 means auto + LayoutUnit letterSpacing; // same unit as fontSize, 0 means auto + LayoutUnit wordSpacing; // same unit as fontSize, 0 means auto + LayoutUnit verticalAlign; // same unit as fontSize, 0 means baseline, positive is up, negative is down + std::span features; + std::span variations; + Hinting hinting = Hinting::Auto; +}; + +struct VerticalMetrics { + LayoutUnit ascent; + LayoutUnit descent; ///< Signed descender; negative values point below the baseline. + LayoutUnit lineGap; + + LayoutUnit height() const noexcept { + return ascent - descent + lineGap; + } +}; + +/** + * @brief Additional font metrics used by text decoration and inline layout. + * + * Values are scaled to the active font size and expressed in layout units, except + * for the fields explicitly represented as floats. + */ +struct ExtendedMetrics { + LayoutUnit spaceAdvanceX{ kZero }; ///< The horizontal advance width for a space character. + LayoutUnit underlinePosition{ kZero }; ///< The underline offset below the baseline. + LayoutUnit lineThickness{ kZero }; ///< The thickness of lines, such as for underline or strikethrough. + LayoutUnit xHeight{ kZero }; ///< The height of the lowercase 'x' character. + LayoutUnit capitalHeight{ kZero }; ///< The height of uppercase characters. +}; +} // namespace Brisk::TextEngine diff --git a/src/graphics/TextEngine_test.cpp b/src/graphics/TextEngine_test.cpp new file mode 100644 index 00000000..df3f42dd --- /dev/null +++ b/src/graphics/TextEngine_test.cpp @@ -0,0 +1,3337 @@ +/* + * Brisk + * + * Cross-platform application framework + * -------------------------------------------------------------- + * + * Copyright (C) 2025 Brisk Developers + * + * This file is part of the Brisk library. + * + * Brisk is dual-licensed under the GNU General Public License, version 2 (GPL-2.0+), + * and a commercial license. You may use, modify, and distribute this software under + * the terms of the GPL-2.0+ license if you comply with its conditions. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ + +#include +#include "TextEngine/include/text_layout/Layout.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace TL = Brisk::TextEngine; + +namespace Brisk::TextEngine { + +constexpr TL::LayoutUnit k12 = TL::fromFloat(12.0f); +constexpr TL::LayoutUnit k16 = TL::fromFloat(16.0f); +constexpr TL::LayoutUnit k24 = TL::fromFloat(24.0f); + +inline std::vector detectScripts(std::u32string_view paragraphText) { + const DocumentSource document(paragraphText); + std::vector result(document.graphemeCount()); + TL::detectScripts(document, CodepointRange{ 0, static_cast(paragraphText.size()) }, + result); + return result; +} + +} // namespace Brisk::TextEngine + +namespace { + +// Test-only PNG renderer. +// The implementation is included from the production source to keep this test helper +// identical to the historical renderer while ensuring it is not part of the library API. + +const TL::FontDatabase* raw(const std::shared_ptr& database) { + return database.get(); +} + +TL::FontDef makeFont() { + static constexpr std::array families{ "Lato" }; + return TL::FontDef{ + .familyNames = families, + .fontSize = TL::k12, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Regular, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = {}, + .variations = {}, + }; +} + +uint32_t scriptTag(std::string_view tag) { + REQUIRE(tag.size() == 4); + return TL::fourCCToUint32(tag); +} + +} // namespace + +TEST_CASE("GraphemeMap::iterate empty text", "[layout2][grapheme]") { + const TL::GraphemeMap map(U""); + std::vector ranges; + map.iterate([&](TL::CodepointRange range) { + ranges.push_back(range); + }); + + REQUIRE(ranges.empty()); +} + +TEST_CASE("GraphemeMap::iterate ascii and complex clusters", "[layout2][grapheme]") { + // "abc" + { + const TL::GraphemeMap map(U"abc"); + std::vector ranges; + map.iterate([&](TL::CodepointRange range) { + ranges.push_back(range); + }); + REQUIRE(ranges.size() == 3); + REQUIRE(ranges[0] == TL::CodepointRange{ 0, 1 }); + REQUIRE(ranges[1] == TL::CodepointRange{ 1, 2 }); + REQUIRE(ranges[2] == TL::CodepointRange{ 2, 3 }); + } + + // Combining character: 'e' (U+0065) + combining acute (U+0301) + 'b' (U+0062) + { + const std::u32string text = { 0x0065, 0x0301, 0x0062 }; + const TL::GraphemeMap map(text); + std::vector ranges; + map.iterate([&](TL::CodepointRange range) { + ranges.push_back(range); + }); + REQUIRE(ranges.size() == 2); + REQUIRE(ranges[0] == TL::CodepointRange{ 0, 2 }); + REQUIRE(ranges[1] == TL::CodepointRange{ 2, 3 }); + } + + // ZWJ sequence: \U0001F468 (U+1F468) + ZWJ (U+200D) + \U0001F4BB (U+1F4BB) + { + const std::u32string text = { 0x1F468, 0x200D, 0x1F4BB }; + const TL::GraphemeMap map(text); + std::vector ranges; + map.iterate([&](TL::CodepointRange range) { + ranges.push_back(range); + }); + REQUIRE(ranges.size() == 1); + REQUIRE(ranges[0] == TL::CodepointRange{ 0, 3 }); + } + + // CRLF sequence with surrounding characters: "a\r\nb" + { + const std::u32string text{ U'a', U'e', U'\u0301', U'\r', U'\n', U'b' }; + const TL::GraphemeMap map(text); + std::vector ranges; + map.iterate([&](TL::CodepointRange range) { + ranges.push_back(range); + }); + REQUIRE(ranges.size() == 4); + REQUIRE(ranges[0] == TL::CodepointRange{ 0, 1 }); // 'a' + REQUIRE(ranges[1] == TL::CodepointRange{ 1, 3 }); // 'e' + acute + REQUIRE(ranges[2] == TL::CodepointRange{ 3, 5 }); // "\r\n" + REQUIRE(ranges[3] == TL::CodepointRange{ 5, 6 }); // 'b' + } + + // Regional indicator flags (emoji flag pairing: \U0001F1FA\U0001F1F8 U+1F1FA U+1F1F8) + { + const std::u32string flagUS{ 0x1F1FA, 0x1F1F8 }; + const TL::GraphemeMap map(flagUS); + std::vector ranges; + map.iterate([&](TL::CodepointRange range) { + ranges.push_back(range); + }); + REQUIRE(ranges.size() == 1); + REQUIRE(ranges[0] == TL::CodepointRange{ 0, 2 }); + } +} + +TEST_CASE("segmentParagraphs produces one paragraph for empty input", "[layout2][paragraph]") { + std::vector> results; + const TL::DocumentSource document(U""); + TL::segmentParagraphs(document, TL::entireText, + [&](TL::CodepointRange range, TL::ParagraphSeparatorKind kind) { + results.emplace_back(range, kind); + }); + + REQUIRE(results.size() == 1); + REQUIRE(results[0].first == TL::CodepointRange{ 0, 0 }); + REQUIRE(results[0].second == TL::ParagraphSeparatorKind::None); +} + +TEST_CASE("segmentParagraphs retains all separator ranges and empty paragraphs", "[layout2][paragraph]") { + const std::u32string text{ U'a', U'\n', U'\r', U'\n', U'\r', U'b', U'\u001C', U'c', + U'\u001D', U'd', U'\u001E', U'e', U'\u0085', U'f', U'\u2029' }; + + std::vector> results; + const TL::DocumentSource document(text); + TL::segmentParagraphs(document, TL::entireText, + [&](TL::CodepointRange range, TL::ParagraphSeparatorKind kind) { + results.emplace_back(range, kind); + }); + + REQUIRE(results.size() == 9); + + // Paragraph 0: "a\n" -> [0, 2) + REQUIRE(results[0].first == TL::CodepointRange{ 0, 2 }); + REQUIRE(results[0].second == TL::ParagraphSeparatorKind::LineFeed); + + // Paragraph 1: "\r\n" -> [2, 4) + REQUIRE(results[1].first == TL::CodepointRange{ 2, 4 }); + REQUIRE(results[1].second == TL::ParagraphSeparatorKind::CarriageReturnLineFeed); + + // Paragraph 2: "\r" -> [4, 5) + REQUIRE(results[2].first == TL::CodepointRange{ 4, 5 }); + REQUIRE(results[2].second == TL::ParagraphSeparatorKind::CarriageReturn); + + // Paragraph 3: "b\x1c" -> [5, 7) + REQUIRE(results[3].first == TL::CodepointRange{ 5, 7 }); + REQUIRE(results[3].second == TL::ParagraphSeparatorKind::InformationSeparator4); + + // Paragraph 4: "c\x1d" -> [7, 9) + REQUIRE(results[4].first == TL::CodepointRange{ 7, 9 }); + REQUIRE(results[4].second == TL::ParagraphSeparatorKind::InformationSeparator3); + + // Paragraph 5: "d\x1e" -> [9, 11) + REQUIRE(results[5].first == TL::CodepointRange{ 9, 11 }); + REQUIRE(results[5].second == TL::ParagraphSeparatorKind::InformationSeparator2); + + // Paragraph 6: "e\x85" -> [11, 13) + REQUIRE(results[6].first == TL::CodepointRange{ 11, 13 }); + REQUIRE(results[6].second == TL::ParagraphSeparatorKind::NextLine); + + // Paragraph 7: "f\u2029" -> [13, 15) + REQUIRE(results[7].first == TL::CodepointRange{ 13, 15 }); + REQUIRE(results[7].second == TL::ParagraphSeparatorKind::ParagraphSeparator); + + // Paragraph 8: trailing empty paragraph -> [15, 15) + REQUIRE(results[8].first == TL::CodepointRange{ 15, 15 }); + REQUIRE(results[8].second == TL::ParagraphSeparatorKind::None); +} + +TEST_CASE("segmentParagraphs keeps visible line separators in paragraph content", "[layout2][paragraph]") { + const std::u32string text{ U'a', U'\v', U'b', U'\f', U'c', U'\u2028', U'd' }; + + std::vector> results; + const TL::DocumentSource document(text); + TL::segmentParagraphs(document, TL::entireText, + [&](TL::CodepointRange range, TL::ParagraphSeparatorKind kind) { + results.emplace_back(range, kind); + }); + + REQUIRE(results.size() == 1); + REQUIRE(results[0].first == TL::CodepointRange{ 0, 7 }); + REQUIRE(results[0].second == TL::ParagraphSeparatorKind::None); +} + +TEST_CASE("resolveBidi empty paragraph", "[layout2][bidi]") { + std::vector> runs; + const TL::DocumentSource emptyDocument(U""); + const TL::Direction level = TL::resolveBidi(emptyDocument, TL::entireText, TL::BaseDirection::DefaultLTR, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + runs.emplace_back(range, runLevel); + }); + + REQUIRE(level == TL::Direction::LeftToRight); + REQUIRE(runs.empty()); + + runs.clear(); + const TL::Direction rtlLevel = + TL::resolveBidi(emptyDocument, TL::entireText, TL::BaseDirection::RightToLeft, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + runs.emplace_back(range, runLevel); + }); + REQUIRE(rtlLevel == TL::Direction::RightToLeft); + REQUIRE(runs.empty()); +} + +TEST_CASE("resolveBidi pure LTR paragraph", "[layout2][bidi]") { + const std::u32string_view text = U"Hello, world!"; + std::vector> runs; + const TL::DocumentSource document(text); + const TL::Direction level = TL::resolveBidi(document, TL::entireText, TL::BaseDirection::DefaultLTR, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + runs.emplace_back(range, runLevel); + }); + + REQUIRE(level == TL::Direction::LeftToRight); + REQUIRE(runs.size() == 1); + REQUIRE(runs[0].first == TL::CodepointRange{ 0, 13 }); + REQUIRE(runs[0].second == 0); +} + +TEST_CASE("resolveBidi pure RTL paragraph", "[layout2][bidi]") { + // Hebrew: \u05E9\u05DC\u05D5\u05DD \u05E2\u05D5\u05DC\u05DD + const std::u32string_view text = U"\u05E9\u05DC\u05D5\u05DD \u05E2\u05D5\u05DC\u05DD"; + std::vector> runs; + const TL::DocumentSource document(text); + const TL::Direction level = TL::resolveBidi(document, TL::entireText, TL::BaseDirection::DefaultLTR, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + runs.emplace_back(range, runLevel); + }); + + REQUIRE(level == TL::Direction::RightToLeft); + REQUIRE(runs.size() == 1); + REQUIRE(runs[0].first == TL::CodepointRange{ 0, 9 }); + REQUIRE(runs[0].second == 1); +} + +TEST_CASE("resolveBidi mixed LTR and RTL text", "[layout2][bidi]") { + // "hello " (LTR, 6 chars) + "\u05E9\u05DC\u05D5\u05DD" (RTL, 4 chars) + " world" (LTR, 6 chars) + const std::u32string_view text = U"hello \u05E9\u05DC\u05D5\u05DD world"; + std::vector> runs; + const TL::DocumentSource document(text); + const TL::Direction level = TL::resolveBidi(document, TL::entireText, TL::BaseDirection::DefaultLTR, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + runs.emplace_back(range, runLevel); + }); + + REQUIRE(level == TL::Direction::LeftToRight); + REQUIRE(runs.size() == 3); + REQUIRE(runs[0].first == TL::CodepointRange{ 0, 6 }); + REQUIRE(runs[0].second == 0); + REQUIRE(runs[1].first == TL::CodepointRange{ 6, 10 }); + REQUIRE(runs[1].second == 1); + REQUIRE(runs[2].first == TL::CodepointRange{ 10, 16 }); + REQUIRE(runs[2].second == 0); +} + +TEST_CASE("resolveBidi base direction policies", "[layout2][bidi]") { + // Force RTL on ASCII + const std::u32string_view ltrText = U"abc"; + std::vector> rtlForcedRuns; + const TL::DocumentSource ltrDocument(ltrText); + const TL::Direction rtlForcedLevel = + TL::resolveBidi(ltrDocument, TL::entireText, TL::BaseDirection::RightToLeft, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + rtlForcedRuns.emplace_back(range, runLevel); + }); + REQUIRE(rtlForcedLevel == TL::Direction::RightToLeft); + REQUIRE(rtlForcedRuns.size() == 1); + REQUIRE(rtlForcedRuns[0].first == TL::CodepointRange{ 0, 3 }); + REQUIRE(rtlForcedRuns[0].second == 2); // LTR text embedded in RTL paragraph gets level 2 + + // Force LTR on Arabic + const std::u32string arabic{ 0x0645, 0x0631 }; + std::vector> ltrForcedRuns; + const TL::DocumentSource arabicDocument(arabic); + const TL::Direction ltrForcedLevel = + TL::resolveBidi(arabicDocument, TL::entireText, TL::BaseDirection::LeftToRight, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + ltrForcedRuns.emplace_back(range, runLevel); + }); + REQUIRE(ltrForcedLevel == TL::Direction::LeftToRight); + REQUIRE(ltrForcedRuns.size() == 1); + REQUIRE(ltrForcedRuns[0].first == TL::CodepointRange{ 0, 2 }); + REQUIRE(ltrForcedRuns[0].second == 1); // RTL text in LTR paragraph gets level 1 + + // Default direction on neutral-only text + std::vector> defaultLtrRuns; + const TL::DocumentSource neutralDocument(U"!? 123"); + const TL::Direction defaultLtrLevel = + TL::resolveBidi(neutralDocument, TL::entireText, TL::BaseDirection::DefaultLTR, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + defaultLtrRuns.emplace_back(range, runLevel); + }); + REQUIRE(defaultLtrLevel == TL::Direction::LeftToRight); + + std::vector> defaultRtlRuns; + const TL::Direction defaultRtlLevel = + TL::resolveBidi(neutralDocument, TL::entireText, TL::BaseDirection::DefaultRTL, + [&](TL::CodepointRange range, TL::BiDiLevel runLevel) { + defaultRtlRuns.emplace_back(range, runLevel); + }); + REQUIRE(defaultRtlLevel == TL::Direction::RightToLeft); +} + +TEST_CASE("segmentParagraphs without trailing separator produces single paragraph with None kind", + "[layout2][paragraph]") { + const std::u32string text = U"Hello, world!"; + + std::vector> results; + const TL::DocumentSource document(text); + TL::segmentParagraphs(document, TL::entireText, + [&](TL::CodepointRange range, TL::ParagraphSeparatorKind kind) { + results.emplace_back(range, kind); + }); + + REQUIRE(results.size() == 1); + REQUIRE(results[0].first == TL::CodepointRange{ 0, static_cast(text.size()) }); + REQUIRE(results[0].second == TL::ParagraphSeparatorKind::None); +} + +TEST_CASE("itemizeScripts empty text", "[layout2][script]") { + std::vector> runs; + const TL::DocumentSource document(U""); + TL::itemizeScripts(document, TL::entireText, 0, {}, {}, + [&](TL::CodepointRange range, const TL::ScriptRun& run) { + runs.emplace_back(range, run); + }); + + REQUIRE(runs.empty()); +} + +TEST_CASE("itemizeScripts separates same-level Unicode scripts", "[layout2][script]") { + const std::u32string_view text = + U"Latin \u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC \u0440\u0443\u0441\u0441\u043A\u0438\u0439 " + U"\u0939\u093F\u0928\u094D\u0926\u0940"; + const auto scripts = TL::detectScripts(text); + std::vector> runs; + const TL::DocumentSource document(text); + TL::itemizeScripts(document, TL::entireText, 0, {}, scripts, + [&](TL::CodepointRange range, const TL::ScriptRun& run) { + runs.emplace_back(range, run); + }); + + REQUIRE(runs.size() == 4); + REQUIRE(runs[0].first == TL::CodepointRange{ 0, 6 }); + REQUIRE(runs[0].second.script == scriptTag("Latn")); + REQUIRE(runs[0].second.level == 0); + + REQUIRE(runs[1].first == TL::CodepointRange{ 6, 15 }); + REQUIRE(runs[1].second.script == scriptTag("Grek")); + REQUIRE(runs[1].second.level == 0); + + REQUIRE(runs[2].first == TL::CodepointRange{ 15, 23 }); + REQUIRE(runs[2].second.script == scriptTag("Cyrl")); + REQUIRE(runs[2].second.level == 0); + + REQUIRE(runs[3].first == TL::CodepointRange{ 23, 29 }); + REQUIRE(runs[3].second.script == scriptTag("Deva")); + REQUIRE(runs[3].second.level == 0); +} + +TEST_CASE("itemizeScripts resolves Common punctuation and matched pairs", "[layout2][script]") { + const std::u32string_view text = U"abc (\u0440\u0443\u0441) xyz"; + const auto scripts = TL::detectScripts(text); + std::vector> runs; + const TL::DocumentSource document(text); + TL::itemizeScripts(document, TL::entireText, 0, {}, scripts, + [&](TL::CodepointRange range, const TL::ScriptRun& run) { + runs.emplace_back(range, run); + }); + + REQUIRE(runs.size() == 3); + REQUIRE(runs[0].first == TL::CodepointRange{ 0, 5 }); + REQUIRE(runs[0].second.script == scriptTag("Latn")); + REQUIRE(runs[0].second.level == 0); + + REQUIRE(runs[1].first == TL::CodepointRange{ 5, 8 }); + REQUIRE(runs[1].second.script == scriptTag("Cyrl")); + REQUIRE(runs[1].second.level == 0); + + REQUIRE(runs[2].first == TL::CodepointRange{ 8, 13 }); + REQUIRE(runs[2].second.script == scriptTag("Latn")); + REQUIRE(runs[2].second.level == 0); +} + +TEST_CASE("itemizeScripts preserves grapheme clusters while resolving inherited marks", "[layout2][script]") { + const std::u32string text{ U'a', U'\u0301', U'\u092C', U'\u093F' }; + const auto scripts = TL::detectScripts(text); + std::vector> runs; + const TL::DocumentSource document(text); + TL::itemizeScripts(document, TL::entireText, 0, {}, scripts, + [&](TL::CodepointRange range, const TL::ScriptRun& run) { + runs.emplace_back(range, run); + }); + + REQUIRE(runs.size() == 2); + REQUIRE(runs[0].first == TL::CodepointRange{ 0, 2 }); + REQUIRE(runs[0].second.script == scriptTag("Latn")); + REQUIRE(runs[0].second.level == 0); + + REQUIRE(runs[1].first == TL::CodepointRange{ 2, 4 }); + REQUIRE(runs[1].second.script == scriptTag("Deva")); + REQUIRE(runs[1].second.level == 0); +} + +TEST_CASE("itemizeScripts honors the script override", "[layout2][script]") { + const std::u32string_view text = U"abc \u0440\u0443\u0441\u0441\u043A\u0438\u0439"; + std::vector> runs; + const TL::DocumentSource document(text); + TL::itemizeScripts(document, TL::entireText, 1, scriptTag("Grek"), {}, + [&](TL::CodepointRange range, const TL::ScriptRun& run) { + runs.emplace_back(range, run); + }); + + REQUIRE(runs.size() == 1); + REQUIRE(runs[0].first == TL::CodepointRange{ 0, static_cast(text.size()) }); + REQUIRE(runs[0].second.script == scriptTag("Grek")); + REQUIRE(runs[0].second.level == 1); +} + +TEST_CASE("itemizeScripts preserves explicit script of nonspacing marks on common base", + "[layout2][script]") { + // U+25CC (DOTTED CIRCLE, Common) + U+05B0 (HEBREW POINT SHEVA, Hebrew Mn) + const std::u32string text{ U'\u25CC', U'\u05B0' }; + const auto scripts = TL::detectScripts(text); + std::vector> runs; + const TL::DocumentSource document(text); + TL::itemizeScripts(document, TL::entireText, 1, {}, scripts, + [&](TL::CodepointRange range, const TL::ScriptRun& run) { + runs.emplace_back(range, run); + }); + + REQUIRE(runs.size() == 1); + REQUIRE(runs[0].first == TL::CodepointRange{ 0, 2 }); + REQUIRE(runs[0].second.script == scriptTag("Hebr")); + REQUIRE(runs[0].second.level == 1); +} + +TEST_CASE("itemizeScripts treats Unknown script as unresolved rather than strong", "[layout2][script]") { + const std::u32string textWithPUA = + std::u32string{ U"abc " } + char32_t(0xE000) + std::u32string{ U" (\u0440\u0443\u0441)" }; + const auto scripts = TL::detectScripts(textWithPUA); + std::vector> runs; + const TL::DocumentSource document(textWithPUA); + TL::itemizeScripts(document, TL::entireText, 0, {}, scripts, + [&](TL::CodepointRange range, const TL::ScriptRun& run) { + runs.emplace_back(range, run); + }); + + REQUIRE(runs.size() == 5); + for (const auto& run : runs) { + REQUIRE(!run.first.empty()); + } + // [0, 4): "abc " -> Latn + REQUIRE(runs[0].first == TL::CodepointRange{ 0, 4 }); + REQUIRE(runs[0].second.script == scriptTag("Latn")); + + // [4, 5): U+E000 -> Zzzz + REQUIRE(runs[1].first == TL::CodepointRange{ 4, 5 }); + REQUIRE(runs[1].second.script == scriptTag("Zzzz")); + + // [5, 7): " (" -> Latn + REQUIRE(runs[2].first == TL::CodepointRange{ 5, 7 }); + REQUIRE(runs[2].second.script == scriptTag("Latn")); + + // [7, 10): "\u0440\u0443\u0441" -> Cyrl + REQUIRE(runs[3].first == TL::CodepointRange{ 7, 10 }); + REQUIRE(runs[3].second.script == scriptTag("Cyrl")); + + // [10, 11): ")" -> Latn (matching '(' on pairedStack resolved to Latn) + REQUIRE(runs[4].first == TL::CodepointRange{ 10, 11 }); + REQUIRE(runs[4].second.script == scriptTag("Latn")); +} + +TEST_CASE("detectScripts empty text", "[layout2][detectScripts]") { + const auto scripts = TL::detectScripts(U""); + REQUIRE(scripts.empty()); +} + +TEST_CASE("detectScripts basic script detection per grapheme", "[layout2][detectScripts]") { + // "A \u03B1 \u0410" -> Latn, Latn (space resolved to Latn), Grek, Grek (space resolved to Grek), Cyrl + const std::u32string_view text = U"A \u03B1 \u0410"; + const auto scripts = TL::detectScripts(text); + + // Graphemes: 'A' (Latn), ' ' (resolved to Latn), '\u03B1' (Grek), ' ' (resolved to Grek), '\u0410' (Cyrl) + REQUIRE(scripts.size() == 5); + REQUIRE(scripts[0] == scriptTag("Latn")); + REQUIRE(scripts[1] == scriptTag("Latn")); + REQUIRE(scripts[2] == scriptTag("Grek")); + REQUIRE(scripts[3] == scriptTag("Grek")); + REQUIRE(scripts[4] == scriptTag("Cyrl")); +} + +TEST_CASE("detectScripts resolves paired punctuation and inherited marks per grapheme", + "[layout2][detectScripts]") { + // "a\u0301 (\u0440\u0443\u0441)" -> + // grapheme 0: 'a' + '\u0301' (Latn) + // grapheme 1: ' ' (Latn) + // grapheme 2: '(' (Latn, opening bracket associated with Latn) + // grapheme 3: '\u0440' (Cyrl) + // grapheme 4: '\u0443' (Cyrl) + // grapheme 5: '\u0441' (Cyrl) + // grapheme 6: ')' (Latn, paired with '(') + const std::u32string text = { U'a', U'\u0301', U' ', U'(', 0x0440, 0x0443, 0x0441, U')' }; + const auto scripts = TL::detectScripts(text); + + REQUIRE(scripts.size() == 7); + REQUIRE(scripts[0] == scriptTag("Latn")); + REQUIRE(scripts[1] == scriptTag("Latn")); + REQUIRE(scripts[2] == scriptTag("Latn")); + REQUIRE(scripts[3] == scriptTag("Cyrl")); + REQUIRE(scripts[4] == scriptTag("Cyrl")); + REQUIRE(scripts[5] == scriptTag("Cyrl")); + REQUIRE(scripts[6] == scriptTag("Latn")); +} + +TEST_CASE("detectScripts handles paired punctuation before first explicit script without corrupting stack", + "[layout2][detectScripts]") { + // "(Hi) [\u0440\u0443\u0441]" + // Graphemes: + // 0: '(' (open pair 0, before any explicit script; resolved to Latn when 'H' is reached) + // 1: 'H' (Latn) + // 2: 'i' (Latn) + // 3: ')' (close pair 0, resolves to Latn) + // 4: ' ' (Common -> Latn) + // 5: '[' (open pair 4, resolves to Latn) + // 6: '\u0440' (Cyrl) + // 7: '\u0443' (Cyrl) + // 8: '\u0441' (Cyrl) + // 9: ']' (close pair 4, matching '[', must resolve to Latn, not Cyrl!) + const std::u32string text = { U'(', U'H', U'i', U')', U' ', U'[', 0x0440, 0x0443, 0x0441, U']' }; + const auto scripts = TL::detectScripts(text); + + REQUIRE(scripts.size() == 10); + REQUIRE(scripts[0] == scriptTag("Latn")); + REQUIRE(scripts[1] == scriptTag("Latn")); + REQUIRE(scripts[2] == scriptTag("Latn")); + REQUIRE(scripts[3] == scriptTag("Latn")); + REQUIRE(scripts[4] == scriptTag("Latn")); + REQUIRE(scripts[5] == scriptTag("Latn")); + REQUIRE(scripts[6] == scriptTag("Cyrl")); + REQUIRE(scripts[7] == scriptTag("Cyrl")); + REQUIRE(scripts[8] == scriptTag("Cyrl")); + REQUIRE(scripts[9] == scriptTag("Latn")); +} + +TEST_CASE("resolveFonts empty script run", "[layout2][font_resolve]") { + std::vector> shapeableRuns; + std::vector> controlRuns; + + const TL::FontDef fontDef = makeFont(); + const std::array fontDefs{ &fontDef }; + const std::array boundaries{ 0, 0 }; + const TL::BoundaryTable fontBoundaries{ boundaries }; + const TL::DocumentSource document(U""); + + TL::resolveFonts( + document, TL::entireText, TL::ScriptRun{ 0, scriptTag("Latn") }, nullptr, fontDefs, fontBoundaries, + [&](TL::CodepointRange range, const TL::ResolvedFont& run) { + shapeableRuns.emplace_back(range, run); + }, + [&](TL::CodepointRange range, char32_t codepoint, const TL::ResolvedFont& run) { + controlRuns.emplace_back(range, codepoint, run); + }); + + REQUIRE(shapeableRuns.empty()); + REQUIRE(controlRuns.empty()); +} + +TEST_CASE("resolveFonts with default font database", "[layout2][font_resolve]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + const TL::FontDef fontDef{ + .familyNames = families, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Regular, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = {}, + .variations = {}, + }; + + const std::u32string_view text = U"Hello\tWorld"; + const std::array fontDefs{ &fontDef }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::BoundaryTable fontBoundaries{ boundaries }; + + std::vector> shapeableRuns; + std::vector> controlRuns; + + const TL::DocumentSource document(text); + TL::resolveFonts( + document, TL::entireText, TL::ScriptRun{ 0, scriptTag("Latn") }, raw(database), fontDefs, + fontBoundaries, + [&](TL::CodepointRange range, const TL::ResolvedFont& run) { + shapeableRuns.emplace_back(range, run); + }, + [&](TL::CodepointRange range, char32_t codepoint, const TL::ResolvedFont& run) { + controlRuns.emplace_back(range, codepoint, run); + }); + + REQUIRE(shapeableRuns.size() == 2); + REQUIRE(controlRuns.size() == 1); + + // "Hello" + REQUIRE(shapeableRuns[0].first == TL::CodepointRange{ 0, 5 }); + REQUIRE(shapeableRuns[0].second.fontHandle); + REQUIRE(shapeableRuns[0].second.fontRunIndex == 0); + + // "\t" + REQUIRE(std::get<0>(controlRuns[0]) == TL::CodepointRange{ 5, 6 }); + REQUIRE(std::get<1>(controlRuns[0]) == U'\t'); + REQUIRE(std::get<2>(controlRuns[0]).fontHandle); + REQUIRE(std::get<2>(controlRuns[0]).fontRunIndex == 0); + + // "World" + REQUIRE(shapeableRuns[1].first == TL::CodepointRange{ 6, 11 }); + REQUIRE(shapeableRuns[1].second.fontHandle); + REQUIRE(shapeableRuns[1].second.fontRunIndex == 0); +} + +TEST_CASE("resolveFonts falls back across family names", "[layout2][font_resolve]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "NoSuchFontHere", "Lato" }; + const TL::FontDef fontDef{ + .familyNames = families, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Regular, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = {}, + .variations = {}, + }; + + const std::u32string_view text = U"Test"; + const std::array fontDefs{ &fontDef }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::BoundaryTable fontBoundaries{ boundaries }; + + std::vector> shapeableRuns; + std::vector> controlRuns; + + const TL::DocumentSource document(text); + TL::resolveFonts( + document, TL::entireText, TL::ScriptRun{ 0, scriptTag("Latn") }, raw(database), fontDefs, + fontBoundaries, + [&](TL::CodepointRange range, const TL::ResolvedFont& run) { + shapeableRuns.emplace_back(range, run); + }, + [&](TL::CodepointRange range, char32_t codepoint, const TL::ResolvedFont& run) { + controlRuns.emplace_back(range, codepoint, run); + }); + + REQUIRE(shapeableRuns.size() == 1); + REQUIRE(shapeableRuns[0].first == TL::CodepointRange{ 0, 4 }); + REQUIRE(shapeableRuns[0].second.fontHandle); + REQUIRE(controlRuns.empty()); +} + +TEST_CASE("resolveFonts splits at font boundary", "[layout2][font_resolve]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families1{ "Lato" }; + static constexpr std::array families2{ "Lato" }; + const TL::FontDef fontDef1{ + .familyNames = families1, + .fontSize = TL::k12, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Regular, + }; + const TL::FontDef fontDef2{ + .familyNames = families2, + .fontSize = TL::k24, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Bold, + }; + + const std::u32string_view text = U"HelloWorld"; + const std::array fontDefs{ &fontDef1, &fontDef2 }; + const std::array boundaries{ 0, 5, 10 }; + const TL::BoundaryTable fontBoundaries{ boundaries }; + + std::vector> shapeableRuns; + std::vector> controlRuns; + + const TL::DocumentSource document(text); + TL::resolveFonts( + document, TL::CodepointRange{ 0, 10 }, TL::ScriptRun{ 0, scriptTag("Latn") }, raw(database), fontDefs, + fontBoundaries, + [&](TL::CodepointRange range, const TL::ResolvedFont& run) { + shapeableRuns.emplace_back(range, run); + }, + [&](TL::CodepointRange range, char32_t codepoint, const TL::ResolvedFont& run) { + controlRuns.emplace_back(range, codepoint, run); + }); + + REQUIRE(shapeableRuns.size() == 2); + REQUIRE(shapeableRuns[0].first == TL::CodepointRange{ 0, 5 }); + REQUIRE(shapeableRuns[0].second.fontRunIndex == 0); + REQUIRE(shapeableRuns[1].first == TL::CodepointRange{ 5, 10 }); + REQUIRE(shapeableRuns[1].second.fontRunIndex == 1); + REQUIRE(controlRuns.empty()); + + // Also verify when resolving a subrange with document-relative coordinates + std::vector> subrangeRuns; + TL::resolveFonts( + document, TL::CodepointRange{ 5, 10 }, TL::ScriptRun{ 0, scriptTag("Latn") }, raw(database), fontDefs, + fontBoundaries, + [&](TL::CodepointRange range, const TL::ResolvedFont& run) { + subrangeRuns.emplace_back(range, run); + }, + [&](TL::CodepointRange, char32_t, const TL::ResolvedFont&) {}); + + REQUIRE(subrangeRuns.size() == 1); + REQUIRE(subrangeRuns[0].first == TL::CodepointRange{ 5, 10 }); + REQUIRE(subrangeRuns[0].second.fontRunIndex == 1); + REQUIRE(shapeableRuns[1].second.fontRunIndex == 1); + REQUIRE(controlRuns.empty()); +} + +TEST_CASE("getDefaultFontDatabase caches open fonts", "[layout2][fontdb]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + TL::FontHandle h1 = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16); + TL::FontHandle h2 = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16); + TL::FontHandle h3 = + database->resolveFont("lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16); + + REQUIRE(h1); + REQUIRE(h1 == h2); + REQUIRE(h1 == h3); + + TL::FontHandle hBold = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Bold, TL::k16); + REQUIRE(hBold); +} + +TEST_CASE("repeated font resolution does not reopen the face", "[layout2][fontdb][cache]") { + auto database = TL::createFontDatabase(); + database->scanDirectory(TL::detail::findFontsDirectory()); + REQUIRE(database != nullptr); + + const TL::FontCacheStats before = database->cacheStats(); + const TL::FontHandle first = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16); + REQUIRE(first); + const TL::FontCacheStats afterFirst = database->cacheStats(); + REQUIRE(afterFirst.ftNewFaceCalls == before.ftNewFaceCalls + 1); + + for (int i = 0; i < 100; ++i) { + REQUIRE(database->resolveFont("lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16) == + first); + } + const TL::FontCacheStats afterRepeated = database->cacheStats(); + REQUIRE(afterRepeated.ftNewFaceCalls == afterFirst.ftNewFaceCalls); + REQUIRE(afterRepeated.ftDoneFaceCalls == afterFirst.ftDoneFaceCalls); +} + +TEST_CASE("addAlias resolves aliases to existing families", "[layout2][fontdb][alias]") { + auto database = TL::createFontDatabase(); + database->scanDirectory(TL::detail::findFontsDirectory()); + REQUIRE(database != nullptr); + + const TL::FontHandle direct = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16); + REQUIRE(direct); + + // Unknown family fails before the alias exists. + REQUIRE_FALSE( + database->resolveFont("LatoAlias", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16)); + + // Alias to a missing family is rejected. + REQUIRE_FALSE(database->addAlias("NoSuchFamily", "LatoAlias")); + + // Alias registration succeeds and resolves to the same handle, case-insensitively. + REQUIRE(database->addAlias("Lato", "LatoAlias")); + REQUIRE(database->resolveFont("LatoAlias", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16) == + direct); + REQUIRE(database->resolveFont("latoalias", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16) == + direct); + + // Rebinding an alias to another existing family works. + REQUIRE(database->addAlias("lato", "LatoAlias")); + REQUIRE(database->resolveFont("LatoAlias", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16) == + direct); +} + +TEST_CASE("font instances cache canonical fractional sizes and remain stable", "[layout2][fontdb][cache]") { + auto database = TL::createFontDatabase(); + database->scanDirectory(TL::detail::findFontsDirectory()); + REQUIRE(database != nullptr); + + const TL::FontHandle a = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::fromFloat(13.25f)); + const TL::FontCacheStats afterA = database->cacheStats(); + const TL::FontHandle same = + database->resolveFont("lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::fromFloat(13.25f)); + const TL::FontHandle b = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::fromFloat(19.75f)); + REQUIRE(a); + REQUIRE(a == same); + REQUIRE(a != b); + REQUIRE(afterA.sizeInitializations == 1); + REQUIRE(database->cacheStats().sizeInitializations == 2); + + const TL::VerticalMetrics aBefore = TL::getVerticalMetrics(database.get(), a); + const TL::VerticalMetrics bMetrics = TL::getVerticalMetrics(database.get(), b); + const TL::VerticalMetrics aAfter = TL::getVerticalMetrics(database.get(), a); + REQUIRE(aBefore.ascent == aAfter.ascent); + REQUIRE(aBefore.descent == aAfter.descent); + REQUIRE(bMetrics.ascent >= aBefore.ascent); + + std::vector growth; + for (int i = 8; i < 80; ++i) { + growth.push_back(database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, + TL::fromFloat(i + 0.5f))); + } + REQUIRE(database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, + TL::fromFloat(13.25f)) == a); + REQUIRE(TL::getVerticalMetrics(database.get(), a).ascent == aBefore.ascent); +} + +TEST_CASE("invalid or unsupported variations are rejected", "[layout2][fontdb][variation]") { + const auto database = TL::getDefaultFontDatabase(); + const std::array variations{ TL::FontVariation{ scriptTag("wght"), TL::fromFloat(500.0f) } }; + REQUIRE_FALSE( + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16, variations)); +} + +TEST_CASE("recent font ring reuses dropped instances and releases older ones", "[layout2][fontdb][cache]") { + auto database = TL::createFontDatabase(); + database->scanDirectory(TL::detail::findFontsDirectory()); + + { + const TL::FontHandle first = database->resolveFont("Lato", TL::FontStyle::Normal, + TL::FontWeight::Regular, TL::fromFloat(11.25f)); + REQUIRE(first); + } + const size_t initialized = database->cacheStats().sizeInitializations; + REQUIRE( + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::fromFloat(11.25f))); + REQUIRE(database->cacheStats().sizeInitializations == initialized); + + for (int i = 20; i < 40; ++i) { + REQUIRE(database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, + TL::fromFloat(static_cast(i)))); + } + REQUIRE(database->cacheStats().sizeCount <= 8); +} + +TEST_CASE("prepared paragraph retains its font database and instances", "[layout2][fontdb][lifetime]") { + auto database = TL::createFontDatabase(); + database->scanDirectory(TL::detail::findFontsDirectory()); + std::weak_ptr weakDatabase = database; + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + const std::u32string_view text = U"persistent paragraph"; + const std::array fontDefs{ &font }; + // prepareDocument requires one boundary per fixture font. For an empty source, use a + // non-empty boundary extent; no grapheme is resolved, and the synthetic buffers below are + // authoritative for the test. + const std::array boundaries{ 0, std::max(1, text.size()) }; + + auto paragraph = + TL::prepareDocument(TL::DocumentSource(text), database, fontDefs, TL::BoundaryTable{ boundaries }); + REQUIRE_FALSE(paragraph.glyphRuns.empty()); + const TL::FontHandle retained = paragraph.glyphRuns.front().fontHandle; + database.reset(); + REQUIRE_FALSE(weakDatabase.expired()); + REQUIRE(retained); + REQUIRE(paragraph.fontDatabase->activate(retained).ftFace != nullptr); + + paragraph = {}; + REQUIRE(weakDatabase.expired()); +} + +TEST_CASE("shapeRun returns nothing for empty input", "[layout2][shape]") { + const TL::FontDef fontDef = makeFont(); + const TL::ResolvedFont run{ + .fontHandle = {}, + .fontRunIndex = 0, + }; + + bool glyphCalled = false; + + const TL::DocumentSource emptyDocument(U""); + TL::shapeRun(emptyDocument, TL::CodepointRange{ 0, 0 }, TL::CodepointRange{ 0, 0 }, nullptr, run, 0, + scriptTag("Latn"), fontDef, {}, [&](const TL::Glyph&, bool) { + glyphCalled = true; + }); + + REQUIRE_FALSE(glyphCalled); +} + +TEST_CASE("shapeRun unrenderable run emits missing glyphs", "[layout2][shape]") { + const TL::FontDef fontDef = makeFont(); + const TL::ResolvedFont run{ + .fontHandle = {}, + .fontRunIndex = 0, + }; + + std::vector glyphs; + + const std::u32string_view text = U"abc"; + const TL::DocumentSource document(text); + TL::shapeRun(document, TL::CodepointRange{ 0, 3 }, TL::CodepointRange{ 0, 3 }, nullptr, run, 0, + scriptTag("Latn"), fontDef, {}, [&](const TL::Glyph& g, bool) { + glyphs.push_back(g); + }); + + REQUIRE(glyphs.size() == 3); + + for (size_t i = 0; i < glyphs.size(); ++i) { + REQUIRE(glyphs[i].glyphId == 0); + REQUIRE(glyphs[i].graphemeIndex == i); + REQUIRE(glyphs[i].xAdvance == TL::kZero); + } +} + +TEST_CASE("shapeRun emits a Lato notdef glyph for missing CJK text", "[layout2][shape][unicode]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + const TL::FontDef fontDef{ + .familyNames = families, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Regular, + }; + const TL::FontHandle handle = database->resolveFont(fontDef); + REQUIRE(handle); + + const TL::ResolvedFont run{ + .fontHandle = handle, + .fontRunIndex = 0, + }; + std::vector glyphs; + const std::u32string text{ 0x4E00 }; + const TL::DocumentSource document(text); + // U+4E00 is absent from Lato and should use its .notdef glyph. + TL::shapeRun(document, TL::CodepointRange{ 0, 1 }, TL::CodepointRange{ 0, 1 }, raw(database), run, 0, + scriptTag("Hani"), fontDef, {}, [&](const TL::Glyph& glyph, bool) { + glyphs.push_back(glyph); + }); + + REQUIRE(glyphs.size() == 1); + REQUIRE(glyphs[0].glyphId == 0); + REQUIRE(glyphs[0].xAdvance > TL::kZero); +} + +TEST_CASE("render missing CJK glyph from Lato to a grayscale PNG", "[layout2][render][unicode]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + font.fontSize = TL::fromFloat(28.0f); + const std::array fonts{ &font }; + const std::u32string_view text = U"Missing CJK: \u4E00"; + const std::array boundaries{ 0, static_cast(text.size()) }; + + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(320.0f)); + + const auto missing = std::ranges::find_if(prepared.glyphs, [](const TL::Glyph& glyph) { + return glyph.glyphId == 0; + }); + REQUIRE(missing != prepared.glyphs.end()); + REQUIRE(missing->xAdvance > TL::kZero); +} + +TEST_CASE("fourCC conversion preserves high bytes", "[layout2][unicode]") { + const std::string_view tag{ "\x80\xFF" + "Az", + 4 }; + REQUIRE(TL::fourCCToUint32(tag) == 0x80FF417A); +} + +TEST_CASE("shapeRun basic Latin with default font database", "[layout2][shape]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + const TL::FontDef fontDef{ + .familyNames = families, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Medium, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = {}, + .variations = {}, + }; + + TL::FontHandle fontHandle = database->resolveFont(fontDef); + REQUIRE(fontHandle); + + const TL::ResolvedFont run{ + .fontHandle = fontHandle, + .fontRunIndex = 0, + }; + + std::vector glyphs; + const std::u32string_view text = U"Hello"; + const TL::DocumentSource document(text); + + TL::shapeRun(document, TL::CodepointRange{ 0, 5 }, TL::CodepointRange{ 0, 5 }, raw(database), run, 0, + scriptTag("Latn"), fontDef, "en", [&](const TL::Glyph& g, bool) { + glyphs.push_back(g); + }); + + REQUIRE(glyphs.size() >= 5); + TL::LayoutUnit totalAdvance = TL::kZero; + for (size_t i = 0; i < glyphs.size(); ++i) { + REQUIRE(glyphs[i].glyphId != 0); + REQUIRE(glyphs[i].xAdvance > TL::kZero); + REQUIRE(glyphs[i].graphemeIndex == i); + totalAdvance += glyphs[i].xAdvance; + } + REQUIRE(totalAdvance > TL::kZero); +} + +TEST_CASE("shapeRun combining marks map to grapheme index", "[layout2][shape]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + const TL::FontDef fontDef{ + .familyNames = families, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Medium, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = {}, + .variations = {}, + }; + + TL::FontHandle fontHandle = database->resolveFont(fontDef); + REQUIRE(fontHandle); + + const TL::ResolvedFont run{ + .fontHandle = fontHandle, + .fontRunIndex = 0, + }; + + // 'e' + combining acute accent U+0301 followed by 'b' + // Grapheme 0: [0, 2) -> 'e' + mark, Grapheme 1: [2, 3) -> 'b' + const std::u32string text{ U'e', U'\u0301', U'b' }; + + std::vector glyphs; + + const TL::DocumentSource document(text); + TL::shapeRun(document, TL::CodepointRange{ 0, 3 }, TL::CodepointRange{ 0, 3 }, raw(database), run, 0, + scriptTag("Latn"), fontDef, {}, [&](const TL::Glyph& g, bool) { + glyphs.push_back(g); + }); + + REQUIRE(!glyphs.empty()); + + for (const auto& glyph : glyphs) { + REQUIRE((glyph.graphemeIndex == 0 || glyph.graphemeIndex == 1)); + } + REQUIRE(glyphs.back().graphemeIndex == 1); +} + +TEST_CASE("shapeRun produces equivalent glyphs for precomposed and decomposed Hangul", + "[layout2][shape][hangul]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Go Noto Kurrent-Regular" }; + TL::FontDef fontDef = makeFont(); + fontDef.familyNames = families; + fontDef.fontSize = TL::k24; + const TL::FontHandle handle = database->resolveFont(fontDef); + REQUIRE(handle); + + const TL::ResolvedFont run{ .fontHandle = handle, .fontRunIndex = 0 }; + std::vector precomposed; + std::vector decomposed; + const std::u32string_view hangulPrecomposed = U"\uD55C"; + const std::u32string_view hangulDecomposed = U"\u1112\u1161\u11AB"; + const TL::DocumentSource precomposedDocument(hangulPrecomposed); + const TL::DocumentSource decomposedDocument(hangulDecomposed); + TL::shapeRun(precomposedDocument, + TL::CodepointRange{ 0, static_cast(hangulPrecomposed.size()) }, + TL::CodepointRange{ 0, static_cast(hangulPrecomposed.size()) }, raw(database), run, + 0, scriptTag("Hang"), fontDef, "ko", [&](const TL::Glyph& glyph, bool) { + precomposed.push_back(glyph); + }); + TL::shapeRun(decomposedDocument, TL::CodepointRange{ 0, static_cast(hangulDecomposed.size()) }, + TL::CodepointRange{ 0, static_cast(hangulDecomposed.size()) }, raw(database), run, + 0, scriptTag("Hang"), fontDef, "ko", [&](const TL::Glyph& glyph, bool) { + decomposed.push_back(glyph); + }); + + REQUIRE(precomposed.size() == decomposed.size()); + REQUIRE(precomposed.size() == 1); + for (size_t i = 0; i < precomposed.size(); ++i) { + CHECK(precomposed[i].glyphId == decomposed[i].glyphId); + CHECK(precomposed[i].xAdvance == decomposed[i].xAdvance); + CHECK(precomposed[i].xOffset == decomposed[i].xOffset); + CHECK(precomposed[i].yOffset == decomposed[i].yOffset); + CHECK(precomposed[i].graphemeIndex == 0); + } +} + +TEST_CASE("shapeRun OpenType features (ligatures)", "[layout2][shape]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array monoFamilies{ "Source Code Pro" }; + static constexpr std::array disableLiga{ TL::OpenTypeFeatureFlag{ + .feature = TL::fourCCToUint32("calt"), .enabled = false } }; + static constexpr std::array enableLiga{ TL::OpenTypeFeatureFlag{ + .feature = TL::fourCCToUint32("calt"), .enabled = true } }; + + const TL::FontDef fontDefDisabled{ + .familyNames = monoFamilies, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Medium, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = disableLiga, + .variations = {}, + }; + + const TL::FontDef fontDefEnabled{ + .familyNames = monoFamilies, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Medium, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = enableLiga, + .variations = {}, + }; + + TL::FontHandle fontDisabled = database->resolveFont(fontDefDisabled); + TL::FontHandle fontEnabled = database->resolveFont(fontDefEnabled); + REQUIRE(fontDisabled); + REQUIRE(fontEnabled); + + const TL::ResolvedFont runDisabled{ + .fontHandle = fontDisabled, + .fontRunIndex = 0, + }; + + const TL::ResolvedFont runEnabled{ + .fontHandle = fontEnabled, + .fontRunIndex = 0, + }; + + const std::u32string_view text = U"!=="; + + std::vector glyphsDisabled; + const TL::DocumentSource document(text); + TL::shapeRun(document, TL::CodepointRange{ 0, static_cast(text.size()) }, + TL::CodepointRange{ 0, static_cast(text.size()) }, raw(database), runDisabled, 0, + scriptTag("Latn"), fontDefDisabled, {}, [&](const TL::Glyph& g, bool) { + glyphsDisabled.push_back(g); + }); + + std::vector glyphsEnabled; + TL::shapeRun(document, TL::CodepointRange{ 0, static_cast(text.size()) }, + TL::CodepointRange{ 0, static_cast(text.size()) }, raw(database), runEnabled, 0, + scriptTag("Latn"), fontDefEnabled, {}, [&](const TL::Glyph& g, bool) { + glyphsEnabled.push_back(g); + }); + + REQUIRE(glyphsDisabled.size() == 3); +} + +TEST_CASE("getVerticalMetrics returns correct metrics using FreeType", "[layout2][metrics]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + SECTION("null font handle returns zero metrics") { + const TL::VerticalMetrics metrics = TL::getVerticalMetrics(nullptr, {}); + REQUIRE(metrics.ascent == TL::kZero); + REQUIRE(metrics.descent == TL::kZero); + REQUIRE(metrics.lineGap == TL::kZero); + } + + SECTION("valid font handle returns non-zero vertical metrics") { + TL::FontHandle fontHandle = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16); + REQUIRE(fontHandle); + + const TL::VerticalMetrics metrics = TL::getVerticalMetrics(raw(database), fontHandle); + REQUIRE(metrics.ascent > TL::kZero); + REQUIRE(metrics.descent < TL::kZero); + } +} + +TEST_CASE("getExtendedMetrics returns additional font metrics using FreeType", "[layout2][metrics]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + SECTION("null font handle returns zero metrics") { + const TL::ExtendedMetrics metrics = TL::getExtendedMetrics(nullptr, {}); + REQUIRE(metrics.spaceAdvanceX == TL::kZero); + REQUIRE(metrics.lineThickness == TL::kZero); + REQUIRE(metrics.xHeight == TL::kZero); + REQUIRE(metrics.capitalHeight == TL::kZero); + } + + SECTION("valid font handle returns non-zero metrics") { + TL::FontHandle fontHandle = + database->resolveFont("Lato", TL::FontStyle::Normal, TL::FontWeight::Regular, TL::k16); + REQUIRE(fontHandle); + + const TL::ExtendedMetrics metrics = TL::getExtendedMetrics(raw(database), fontHandle); + REQUIRE(metrics.spaceAdvanceX > TL::kZero); + REQUIRE(metrics.lineThickness > TL::kZero); + REQUIRE(metrics.xHeight > TL::kZero); + REQUIRE(metrics.capitalHeight > TL::kZero); + } +} + +TEST_CASE("full pipeline shapes multi-paragraph bidirectional text", "[layout2][pipeline]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + const TL::FontDef fontDef = makeFont(); + static constexpr std::array alternateFamilies{ "Source Code Pro" }; + const TL::FontDef alternateFontDef{ + .familyNames = alternateFamilies, + .fontSize = TL::k12, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Regular, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = {}, + .variations = {}, + }; + const std::array fonts{ &fontDef, &alternateFontDef }; + const std::array partialFonts{ &alternateFontDef, &fontDef }; + + // Paragraph 1 is LTR with an embedded Hebrew run. Paragraph 2 is RTL with + // an embedded Latin run. Paragraph 3 mixes Greek and English text. All paragraphs + // contain paired punctuation and combining marks where applicable. + const std::u32string text{ U'E', U'n', U'g', U'l', U'i', U's', U'h', + U' ', U'(', U'e', U'\u0301', U')', U' ', U'\u05E9', + U'\u05B8', U'\u05DC', U'\u05D5', U'\u05DD', U'\n', U'(', U'\u05E9', + U'\u05B8', U'\u05DC', U'\u05D5', U'\u05DD', U')', U' ', U'c', + U'a', U'f', U'\u00E9', U'\n', U'\u0393', U'\u03B5', U'\u03B9', + U'\u03AC', U' ', U'(', U'\u03BA', U'\u03CC', U'\u03C3', U'\u03BC', + U'\u03B5', U')', U' ', U'E', U'n', U'g', U'l', + U'i', U's', U'h' }; + + struct ShapedRecord { + TL::BiDiLevel level; + TL::Direction direction; + size_t glyphCount; + size_t graphemeCount; + TL::LayoutUnit advance; + }; + + std::vector> paragraphs; + std::vector paragraphDirections; + std::vector shapedRuns; + std::vector shapedScripts; + std::vector shapedFontRuns; + std::vector shapedRanges; + + const TL::DocumentSource document(text); + TL::segmentParagraphs( + document, TL::entireText, // + [&](TL::CodepointRange paragraphRange, TL::ParagraphSeparatorKind separator) { + const auto paragraphIndex = paragraphs.size(); + paragraphs.emplace_back(paragraphRange, separator); + + const auto separatorLength = TL::paragraphSeparatorKindCodepoints(separator); + const TL::CodepointRange paragraphContent{ paragraphRange.min, + paragraphRange.max - separatorLength }; + const auto paragraphGraphemeRange = document.graphemes.toGraphemeRange(paragraphContent); + std::vector paragraphScripts(paragraphGraphemeRange.distance()); + TL::detectScripts(document, paragraphContent, paragraphScripts); + const TL::GraphemeMap paragraphGraphemes( + text.substr(paragraphContent.min, paragraphContent.distance())); + const TL::Direction paragraphDirection = TL::resolveBidi( + document, paragraphContent, TL::BaseDirection::DefaultLTR, // + [&](TL::CodepointRange bidiRange, TL::BiDiLevel bidiLevel) { + const auto firstGrapheme = + paragraphGraphemes.toGrapheme(bidiRange.min - paragraphContent.min); + const auto lastGrapheme = + paragraphGraphemes.toGrapheme(bidiRange.max - 1 - paragraphContent.min) + 1; + const std::span bidiScripts{ paragraphScripts.data() + firstGrapheme, + lastGrapheme - firstGrapheme }; + + TL::itemizeScripts( + document, bidiRange, bidiLevel, {}, bidiScripts, // + [&](TL::CodepointRange scriptRange, const TL::ScriptRun& scriptRun) { + const bool firstParagraphCodepoint = + paragraphIndex == 2 && scriptRange.min == paragraphRange.min; + const std::array fontBoundaryStorage{ + 0, + firstParagraphCodepoint ? scriptRange.min + 1u + : static_cast(text.size()), + static_cast(text.size()) + }; + const std::span scriptFonts = + firstParagraphCodepoint + ? std::span{ partialFonts } + : std::span{ fonts }.first(1); + const TL::BoundaryTable scriptFontBoundaries{ + std::span{ fontBoundaryStorage }.first( + firstParagraphCodepoint ? 3 : 2) + }; + + TL::resolveFonts( + document, scriptRange, scriptRun, raw(database), scriptFonts, + scriptFontBoundaries, // + [&](TL::CodepointRange fontRange, const TL::ResolvedFont& resolvedRun) { + size_t glyphCount = 0; + TL::shapeRun(document, paragraphContent, fontRange, raw(database), + resolvedRun, bidiLevel, scriptRun.script, + *scriptFonts[resolvedRun.fontRunIndex], {}, // + [&](const TL::Glyph&, bool) { + ++glyphCount; + }); + + shapedRuns.push_back(ShapedRecord{ + .level = bidiLevel, + .direction = TL::directionFromLevel(bidiLevel), + .glyphCount = glyphCount, + .graphemeCount = + TL::GraphemeMap(text.substr(fontRange.min, fontRange.distance())) + .graphemeCount(), + .advance = TL::kZero, + }); + shapedScripts.push_back(scriptRun.script); + shapedFontRuns.push_back(firstParagraphCodepoint + ? (resolvedRun.fontRunIndex == 0 ? 1u : 0u) + : 0u); + shapedRanges.push_back(fontRange); + }, + [&](TL::CodepointRange, char32_t, const TL::ResolvedFont&) {}); + }); + }); + paragraphDirections.push_back(paragraphDirection); + }); + + REQUIRE(paragraphs.size() == 3); + REQUIRE(paragraphs[0].second == TL::ParagraphSeparatorKind::LineFeed); + REQUIRE(paragraphs[1].second == TL::ParagraphSeparatorKind::LineFeed); + REQUIRE(paragraphs[2].second == TL::ParagraphSeparatorKind::None); + REQUIRE(paragraphDirections.size() == 3); + REQUIRE(paragraphDirections[0] == TL::Direction::LeftToRight); + REQUIRE(paragraphDirections[1] == TL::Direction::RightToLeft); + REQUIRE(paragraphDirections[2] == TL::Direction::LeftToRight); + + REQUIRE(shapedRuns.size() >= 5); + bool sawLtr = false; + bool sawRtl = false; + bool sawGreek = false; + bool sawDistinctGreekFont = false; + for (const ShapedRecord& run : shapedRuns) { + REQUIRE(run.glyphCount > 0); + REQUIRE(run.graphemeCount > 0); + const bool validLevel = + run.level == (run.direction == TL::Direction::RightToLeft ? 1 : 0) || run.level > 1; + REQUIRE(validLevel); + if (run.direction == TL::Direction::LeftToRight) { + sawLtr = true; + } else { + sawRtl = true; + } + } + REQUIRE(shapedScripts.size() == shapedRuns.size()); + REQUIRE(shapedFontRuns.size() == shapedRuns.size()); + REQUIRE(shapedRanges.size() == shapedRuns.size()); + bool sawDefaultFontAfterAlternate = false; + for (size_t i = 0; i < shapedRuns.size(); ++i) { + if (shapedFontRuns[i] == 1 && shapedRanges[i].distance() == 1) { + sawDistinctGreekFont = true; + } + if (shapedScripts[i] == scriptTag("Grek")) { + sawGreek = true; + } + if (shapedFontRuns[i] == 0 && shapedRanges[i].min > paragraphs[2].first.min) { + sawDefaultFontAfterAlternate = true; + } + } + REQUIRE(sawLtr); + REQUIRE(sawRtl); + REQUIRE(sawGreek); + REQUIRE(sawDistinctGreekFont); + REQUIRE(sawDefaultFontAfterAlternate); +} + +TEST_CASE("GraphemeMap empty text", "[layout2][grapheme]") { + const TL::GraphemeMap map(U""); + + REQUIRE(map.graphemeCount() == 0); + REQUIRE(map.codepointCount() == 0); +} + +TEST_CASE("GraphemeMap ascii text", "[layout2][grapheme]") { + const TL::GraphemeMap map(U"abc"); + + REQUIRE(map.graphemeCount() == 3); + REQUIRE(map.codepointCount() == 3); + + for (uint32_t i = 0; i < 3; ++i) { + REQUIRE(map.toCodepoint(i) == i); + REQUIRE(map.toGrapheme(i) == i); + REQUIRE(map.codepointRangeForGrapheme(i) == TL::CodepointRange{ i, i + 1 }); + } +} + +TEST_CASE("GraphemeMap combining cluster", "[layout2][grapheme]") { + // 'e' (U+0065) + combining acute (U+0301) + 'b' (U+0062) + const std::u32string text = { 0x0065, 0x0301, 0x0062 }; + const TL::GraphemeMap map(text); + + REQUIRE(map.graphemeCount() == 2); + REQUIRE(map.codepointCount() == 3); + + REQUIRE(map.toCodepoint(0) == 0); + REQUIRE(map.toCodepoint(1) == 2); + REQUIRE(map.codepointRangeForGrapheme(0) == TL::CodepointRange{ 0, 2 }); + REQUIRE(map.codepointRangeForGrapheme(1) == TL::CodepointRange{ 2, 3 }); + + REQUIRE(map.toGrapheme(0) == 0); + REQUIRE(map.toGrapheme(1) == 0); // combining mark belongs to first cluster + REQUIRE(map.toGrapheme(2) == 1); +} + +TEST_CASE("GraphemeMap ZWJ sequence", "[layout2][grapheme]") { + const std::u32string text = { 0x1F468, 0x200D, 0x1F4BB }; + const TL::GraphemeMap map(text); + + REQUIRE(map.graphemeCount() == 1); + REQUIRE(map.codepointCount() == 3); + REQUIRE(map.toCodepoint(0) == 0); + REQUIRE(map.toGrapheme(0) == 0); + REQUIRE(map.toGrapheme(1) == 0); + REQUIRE(map.toGrapheme(2) == 0); + REQUIRE(map.codepointRangeForGrapheme(0) == TL::CodepointRange{ 0, 3 }); +} + +TEST_CASE("GraphemeMap regional indicator sequence", "[layout2][grapheme]") { + const std::u32string text{ 0x1F1FA, 0x1F1F8, U'a' }; + const TL::GraphemeMap map(text); + + REQUIRE(map.graphemeCount() == 2); + REQUIRE(map.codepointCount() == 3); + REQUIRE(map.toCodepoint(0) == 0); + REQUIRE(map.toCodepoint(1) == 2); + REQUIRE(map.toCodepoint(2) == 3); + REQUIRE(map.toGrapheme(0) == 0); + REQUIRE(map.toGrapheme(1) == 0); + REQUIRE(map.toGrapheme(2) == 1); + REQUIRE(map.toGrapheme(3) == 2); + REQUIRE(map.codepointRangeForGrapheme(0) == TL::CodepointRange{ 0, 2 }); + REQUIRE(map.codepointRangeForGrapheme(1) == TL::CodepointRange{ 2, 3 }); + + REQUIRE(map.toCodepointRange(TL::GraphemeRange{ 0, 0 }) == TL::CodepointRange{ 0, 0 }); + REQUIRE(map.toCodepointRange(TL::GraphemeRange{ 0, 1 }) == TL::CodepointRange{ 0, 2 }); + REQUIRE(map.toCodepointRange(TL::GraphemeRange{ 1, 2 }) == TL::CodepointRange{ 2, 3 }); + REQUIRE(map.toCodepointRange(TL::GraphemeRange{ 0, 2 }) == TL::CodepointRange{ 0, 3 }); + + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 0, 0 }) == TL::GraphemeRange{ 0, 0 }); + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 1, 1 }) == TL::GraphemeRange{ 0, 0 }); + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 2, 2 }) == TL::GraphemeRange{ 1, 1 }); + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 3, 3 }) == TL::GraphemeRange{ 2, 2 }); + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 0, 1 }) == TL::GraphemeRange{ 0, 1 }); + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 0, 2 }) == TL::GraphemeRange{ 0, 1 }); + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 1, 2 }) == TL::GraphemeRange{ 0, 1 }); + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 0, 3 }) == TL::GraphemeRange{ 0, 2 }); + REQUIRE(map.toGraphemeRange(TL::CodepointRange{ 2, 3 }) == TL::GraphemeRange{ 1, 2 }); +} + +TEST_CASE("GraphemeMap terminating entries", "[layout2][grapheme]") { + // codepointToGrapheme has size == codepoint count + 1, with the last entry + // equal to the grapheme count; graphemeToCodepoint's last entry equals the + // total codepoint count. Both hold for the text constructor, the slice + // constructor, and the empty map. + const std::u32string text = { 0x0065, 0x0301, 0x0062 }; // "\u00E9b": 3 cps, 2 graphemes + const TL::GraphemeMap map(text); + + REQUIRE(map.toGrapheme(map.codepointCount()) == map.graphemeCount()); + REQUIRE(map.toCodepoint(map.graphemeCount()) == map.codepointCount()); + + const TL::GraphemeMap sliced(map, TL::GraphemeRange{ 1, 2 }); // single "b" cluster + REQUIRE(sliced.graphemeCount() == 1); + REQUIRE(sliced.codepointCount() == 1); + REQUIRE(sliced.toGrapheme(sliced.codepointCount()) == sliced.graphemeCount()); + REQUIRE(sliced.toCodepoint(sliced.graphemeCount()) == sliced.codepointCount()); + + const TL::GraphemeMap emptySlice(map, TL::GraphemeRange{ 1, 1 }); + REQUIRE(emptySlice.graphemeCount() == 0); + REQUIRE(emptySlice.codepointCount() == 0); + REQUIRE(emptySlice.toGrapheme(0) == 0); + REQUIRE(emptySlice.toCodepoint(0) == 0); + + const TL::GraphemeMap empty; + REQUIRE(empty.toGrapheme(0) == 0); + REQUIRE(empty.toCodepoint(0) == 0); +} + +TEST_CASE("DocumentSource basic accessors", "[layout2][document_source]") { + const std::u32string text = U"Hello, \U0001F1FA\U0001F1F8 world!"; + const TL::DocumentSource doc(text); + REQUIRE(doc.codepointCount() == text.size()); + REQUIRE(doc.graphemeCount() == doc.graphemes.graphemeCount()); + REQUIRE(doc.substr(TL::CodepointRange{ 0, 5 }) == U"Hello"); +} + +TEST_CASE("BoundaryTable validates and indexes boundaries", "[layout2][boundary]") { + const std::array boundaries{ 0, 2, 5, 5 }; + const TL::BoundaryTable invalid{ boundaries }; + REQUIRE_FALSE(invalid.correct()); + + const std::array validBoundaries{ 0, 2, 5, 9 }; + const TL::BoundaryTable table{ validBoundaries }; + REQUIRE(table.correct()); + REQUIRE(table.count() == 3); + REQUIRE(table.total() == 9); + REQUIRE_FALSE(table.empty()); + REQUIRE(table.begin(1) == 2); + REQUIRE(table.end(1) == 5); + REQUIRE(table.rangeOf(1) == Brisk::Range{ 2, 5 }); + REQUIRE(table.indexOf(0) == 0); + REQUIRE(table.indexOf(1) == 0); + REQUIRE(table.indexOf(2) == 1); + REQUIRE(table.indexOf(8) == 2); + + const std::array emptyBoundaries{ 0 }; + const TL::BoundaryTable emptyText{ emptyBoundaries }; + REQUIRE(emptyText.correct()); + REQUIRE(emptyText.count() == 0); + REQUIRE(emptyText.total() == 0); + REQUIRE(emptyText.empty()); +} + +TEST_CASE("paragraphSeparatorKindCodepoints covers every separator kind", "[layout2][paragraph]") { + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::None) == 0); + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::LineFeed) == 1); + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::CarriageReturn) == 1); + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::CarriageReturnLineFeed) == 2); + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::InformationSeparator4) == 1); + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::InformationSeparator3) == 1); + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::InformationSeparator2) == 1); + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::NextLine) == 1); + REQUIRE(TL::paragraphSeparatorKindCodepoints(TL::ParagraphSeparatorKind::ParagraphSeparator) == 1); +} + +TEST_CASE("prepareDocument stores multiple paragraphs in document-relative flat buffers", + "[layout2][document]") { + const std::u32string text = U"one\ntwo\n"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + const TL::FontDef font = makeFont(); + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + + const TL::PreparedDocument document = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + + REQUIRE(document.paragraphs.size() == 3); + CHECK(document.paragraphs[0].paragraphRange == TL::CodepointRange{ 0, 3 }); + CHECK(document.paragraphs[0].paragraphSeparatorKind == TL::ParagraphSeparatorKind::LineFeed); + CHECK(document.paragraphs[1].paragraphRange == TL::CodepointRange{ 4, 7 }); + CHECK(document.paragraphs[2].paragraphRange == TL::CodepointRange{ 8, 8 }); + CHECK(document.graphemes.size() == document.graphemeMap.graphemeCount()); + CHECK(document.graphemeAdvancePrefix.size() == document.graphemes.size() + 1); + CHECK(document.paragraphs[0].graphemeRange.max <= document.paragraphs[1].graphemeRange.min); + CHECK(document.paragraphs[1].graphemeRange.max <= document.paragraphs[2].graphemeRange.min); +} + +TEST_CASE("document layout and queries use document-relative offsets across paragraphs", + "[layout2][document][queries]") { + const std::u32string text = U"ab\ncd"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + const TL::FontDef font = makeFont(); + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument document = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(document, {}, TL::fromFloat(1000.0f)); + + REQUIRE(layout.lines.size() == 2); + CHECK(layout.lines[0].graphemeRange.min == 0); + CHECK(layout.lines[1].graphemeRange.min > layout.lines[0].graphemeRange.max); + CHECK(layout.lines[1].baseDirection == TL::Direction::LeftToRight); + + const TL::CaretPosition first = TL::caretPosition(document, layout, { 0, TL::CaretAffinity::Downstream }); + const TL::CaretPosition second = TL::caretPosition( + document, layout, { document.paragraphs[1].graphemeRange.min, TL::CaretAffinity::Downstream }); + CHECK(first.line == 0); + CHECK(second.line == 1); + + const auto rects = [&] { + std::vector result; + TL::selectionRects(document, layout, { 0, document.graphemeMap.graphemeCount() }, + [&](const TL::SelectionRect& rect) { + result.push_back(rect); + }); + return result; + }(); + REQUIRE(rects.size() == 2); + CHECK(rects[0].line == 0); + CHECK(rects[1].line == 1); +} + +TEST_CASE("caret affinity selects the correct paragraph near separators", + "[layout2][document][queries][caret][separator]") { + const std::u32string text = U"a\nb"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + const TL::FontDef font = makeFont(); + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument document = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(document, {}, TL::fromFloat(1000.0f)); + + REQUIRE(document.paragraphs.size() == 2); + REQUIRE(document.paragraphs[0].graphemeRange == TL::GraphemeRange{ 0, 1 }); + REQUIRE(document.paragraphs[1].graphemeRange == TL::GraphemeRange{ 2, 3 }); + + // Grapheme 1 is the separator slot between the two paragraph content ranges. + const TL::CaretPosition upstream = + TL::caretPosition(document, layout, { 1, TL::CaretAffinity::Upstream }); + const TL::CaretPosition downstream = + TL::caretPosition(document, layout, { 1, TL::CaretAffinity::Downstream }); + + REQUIRE(upstream.line == 0); + REQUIRE(downstream.line == 1); + REQUIRE(upstream.x == layout.lines[0].originX + layout.lines[0].width); + REQUIRE(downstream.x == layout.lines[1].originX); +} + +TEST_CASE("document layout broadcasts paragraph settings and applies per-paragraph spans", + "[layout2][document][alignment]") { + const std::u32string text = U"a\nbb"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + const TL::FontDef font = makeFont(); + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument document = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + + const TL::LayoutUnit indent = TL::fromFloat(10.0f); + const std::array indents{ indent }; + const TL::DocumentLayout broadcast = + TL::layoutPreparedDocument(document, {}, TL::fromFloat(100.0f), {}, indents); + REQUIRE(broadcast.lines.size() == 2); + REQUIRE(broadcast.lines[0].originX == indent); + REQUIRE(broadcast.lines[1].originX == indent); + + const std::array alignments{ TL::TextAlignment::Left, TL::TextAlignment::Right }; + const std::array noIndents{ TL::kZero, TL::kZero }; + const TL::DocumentLayout perParagraph = + TL::layoutPreparedDocument(document, alignments, TL::fromFloat(100.0f), {}, noIndents); + REQUIRE(perParagraph.lines.size() == 2); + REQUIRE(perParagraph.lines[0].originX == TL::kZero); + REQUIRE(perParagraph.lines[1].originX > TL::kZero); +} + +TEST_CASE("document preparation resolves mixed paragraph directions", "[layout2][document][bidi]") { + const std::u32string text = U"abc\n\u05D0\u05D1\u05D2"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + const TL::FontDef font = makeFont(); + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const std::array directions{ TL::BaseDirection::DefaultLTR, TL::BaseDirection::RightToLeft }; + const TL::PreparedDocument document = TL::prepareDocument(TL::DocumentSource(text), database, fonts, + TL::BoundaryTable{ boundaries }, directions); + REQUIRE(document.paragraphs.size() == 2); + REQUIRE(document.paragraphs[0].baseDirection == TL::Direction::LeftToRight); + REQUIRE(document.paragraphs[1].baseDirection == TL::Direction::RightToLeft); + + const TL::DocumentLayout layout = TL::layoutPreparedDocument(document, {}, TL::fromFloat(1000.0f)); + REQUIRE(layout.lines.size() == 2); + REQUIRE(layout.lines[0].baseDirection == TL::Direction::LeftToRight); + REQUIRE(layout.lines[1].baseDirection == TL::Direction::RightToLeft); +} + +TEST_CASE("document layout preserves tabs and line-height across paragraphs", + "[layout2][document][tabs][line-height]") { + const std::u32string text = U"a\tb\nc\td"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + TL::FontDef font = makeFont(); + font.lineHeight = TL::fromFloat(40.0f); + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument document = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + const TL::TabStops tabs{ .firstStop = TL::fromFloat(20.0f), .interval = TL::fromFloat(20.0f) }; + const TL::DocumentLayout layout = TL::layoutPreparedDocument(document, {}, TL::fromFloat(1000.0f), tabs); + + REQUIRE(layout.lines.size() == 2); + REQUIRE(layout.lines[0].width == layout.lines[1].width); + REQUIRE(layout.lines[1].baselineY - layout.lines[0].baselineY == TL::fromFloat(40.0f)); +} + +TEST_CASE("document queries handle separator gaps, empty paragraphs, and cross-paragraph selections", + "[layout2][document][queries][edge]") { + const std::u32string text = U"a\n\nb\n"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + TL::FontDef font = makeFont(); + static constexpr std::array families{ "Lato" }; + font.familyNames = families; + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument document = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(document, {}, TL::fromFloat(1000.0f)); + + REQUIRE(document.paragraphs.size() == 4); + REQUIRE(layout.lines.size() == 4); + REQUIRE(document.paragraphs[1].paragraphRange.empty()); + REQUIRE(document.paragraphs[3].paragraphRange.empty()); + + const TL::GraphemeIndex firstEmpty = document.paragraphs[1].graphemeRange.min; + const TL::CaretPosition emptyCaret = + TL::caretPosition(document, layout, { firstEmpty, TL::CaretAffinity::Downstream }); + REQUIRE(emptyCaret.line == 1); + REQUIRE(emptyCaret.x == TL::kZero); + + const TL::GraphemeIndex secondParagraph = document.paragraphs[2].graphemeRange.min; + const TL::CaretPosition beforeSecond = + TL::caretPosition(document, layout, { secondParagraph, TL::CaretAffinity::Downstream }); + REQUIRE(beforeSecond.line == 2); + + const TL::CaretIndex hit = TL::hitTestPoint(document, layout, TL::kZero, layout.lines[2].baselineY); + CHECK(hit.grapheme == secondParagraph); + + std::vector rects; + TL::selectionRects(document, layout, { 0, document.graphemeMap.graphemeCount() }, + [&](const TL::SelectionRect& rect) { + rects.push_back(rect); + }); + REQUIRE(rects.size() == 2); + REQUIRE(rects[0].line == 0); + REQUIRE(rects[1].line == 2); +} + +TEST_CASE("selectionRects spans three paragraphs and skips a fully empty paragraph", + "[layout2][document][queries][selection][edge]") { + const std::u32string text = U"ab\n\ncd"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + const TL::FontDef font = makeFont(); + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument document = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(document, {}, TL::fromFloat(1000.0f)); + + REQUIRE(document.paragraphs.size() == 3); + REQUIRE(document.paragraphs[0].paragraphRange == TL::CodepointRange{ 0, 2 }); // "ab" + REQUIRE(document.paragraphs[1].paragraphRange.empty()); // fully empty paragraph + REQUIRE(document.paragraphs[2].paragraphRange == TL::CodepointRange{ 4, 6 }); // "cd" + REQUIRE(layout.lines.size() == 3); + + // Select from the second grapheme of "ab" through the first grapheme of "cd", which spans the + // separator gap and the fully empty middle paragraph. + const TL::GraphemeIndex selectionStart = document.paragraphs[0].graphemeRange.min + 1; + const TL::GraphemeIndex selectionEnd = document.paragraphs[2].graphemeRange.min + 1; + std::vector rects; + TL::selectionRects(document, layout, { selectionStart, selectionEnd }, + [&](const TL::SelectionRect& rect) { + rects.push_back(rect); + }); + + // The empty middle paragraph contributes no rectangle of its own. + REQUIRE(rects.size() == 2); + REQUIRE(rects[0].line == 0); + REQUIRE(rects[1].line == 2); + REQUIRE(rects[0].x1 > rects[0].x0); + REQUIRE(rects[1].x1 > rects[1].x0); + // The first rectangle covers only the second grapheme of "ab" ('b'), not the whole word. + REQUIRE(rects[0].x0 > layout.lines[0].originX); +} + +TEST_CASE("caretPosition and hitTestPoint agree at every paragraph separator in a multi-paragraph document", + "[layout2][document][queries][caret][edge]") { + const std::u32string text = U"a\nbb\nc"; + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + const TL::FontDef font = makeFont(); + const std::array fonts{ &font }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument document = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(document, {}, TL::fromFloat(1000.0f)); + + REQUIRE(document.paragraphs.size() == 3); + REQUIRE(layout.lines.size() == 3); + REQUIRE(document.paragraphs[0].graphemeRange == TL::GraphemeRange{ 0, 1 }); // "a" + REQUIRE(document.paragraphs[1].graphemeRange == TL::GraphemeRange{ 2, 4 }); // "bb" + REQUIRE(document.paragraphs[2].graphemeRange == TL::GraphemeRange{ 5, 6 }); // "c" + + // Grapheme 1 is the separator between paragraphs 0 and 1; grapheme 4 is the separator between + // paragraphs 1 and 2. + const std::array separatorGraphemes{ 1, 4 }; + for (size_t i = 0; i < separatorGraphemes.size(); ++i) { + const TL::GraphemeIndex separator = separatorGraphemes[i]; + const TL::CaretPosition upstream = + TL::caretPosition(document, layout, { separator, TL::CaretAffinity::Upstream }); + const TL::CaretPosition downstream = + TL::caretPosition(document, layout, { separator, TL::CaretAffinity::Downstream }); + + REQUIRE(upstream.line == static_cast(i)); + REQUIRE(downstream.line == static_cast(i + 1)); + + // Hit-testing the resolved caret position on its own line must reproduce the same caret. + const TL::CaretIndex upstreamHit = + TL::hitTestPoint(document, layout, upstream.x, layout.lines[upstream.line].baselineY); + const TL::CaretIndex downstreamHit = + TL::hitTestPoint(document, layout, downstream.x, layout.lines[downstream.line].baselineY); + + const TL::CaretPosition upstreamRoundTrip = TL::caretPosition(document, layout, upstreamHit); + const TL::CaretPosition downstreamRoundTrip = TL::caretPosition(document, layout, downstreamHit); + REQUIRE(upstreamRoundTrip.x == upstream.x); + REQUIRE(upstreamRoundTrip.line == upstream.line); + REQUIRE(downstreamRoundTrip.x == downstream.x); + REQUIRE(downstreamRoundTrip.line == downstream.line); + } +} + +// --------------------------------------------------------------------------- +// GreedyLineBreaker -- tested with hand-crafted PreparedDocument buffers. +// --------------------------------------------------------------------------- + +namespace { + +constexpr auto kNoBreak = TL::LineBreakKind::NoBreak; + +struct TestGrapheme { + TL::GraphemeFlags value; + TL::LayoutUnit advance; +}; + +TestGrapheme advance(TL::LayoutUnit width, bool trimmableWhitespace = false) { + return TestGrapheme{ + .value = TL::GraphemeFlags{ .trimmableWhitespace = trimmableWhitespace }, + .advance = width, + }; +} + +TestGrapheme tab() { + return TestGrapheme{ + .value = + TL::GraphemeFlags{ + .trimmableWhitespace = true, + .isTab = true, + }, + .advance = TL::kZero, + }; +} + +TL::PreparedDocument preparedDocument(std::u32string_view text, std::vector opportunities, + std::vector graphemes, + std::vector runs = {}) { + const TL::FontDef fixtureFont = makeFont(); + const std::array fixtureFonts{ &fixtureFont }; + const std::array boundaries{ 0, std::max(1, text.size()) }; + TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), TL::getDefaultFontDatabase(), fixtureFonts, + TL::BoundaryTable{ boundaries }); + prepared.paragraphs[0].paragraphRange = { 0, static_cast(text.size()) }; + prepared.paragraphs[0].graphemeRange = { 0, prepared.graphemeMap.graphemeCount() }; + prepared.paragraphs[0].glyphRange = { 0, 0 }; + prepared.paragraphs[0].glyphRunRange = { 0, 0 }; + prepared.breakOpportunities = std::move(opportunities); + prepared.graphemes.clear(); + prepared.graphemes.reserve(graphemes.size()); + prepared.graphemeAdvancePrefix.clear(); + prepared.graphemeAdvancePrefix.reserve(graphemes.size() + 1); + prepared.graphemeAdvancePrefix.push_back(TL::kZero); + for (const TestGrapheme& grapheme : graphemes) { + prepared.graphemes.push_back(grapheme.value); + prepared.graphemeAdvancePrefix.push_back(prepared.graphemeAdvancePrefix.back() + grapheme.advance); + } + if (runs.empty() && !text.empty()) { + runs.push_back(TL::GlyphRun{ + .codepointRange = { 0, static_cast(text.size()) }, + .graphemeRange = { 0, prepared.graphemeMap.graphemeCount() }, + .glyphRange = { 0, 0 }, + }); + } + prepared.glyphRuns = std::move(runs); + prepared.paragraphs[0].glyphRunRange = { 0, static_cast(prepared.glyphRuns.size()) }; + return prepared; +} + +TL::LayoutUnit graphemeAdvance(const TL::PreparedDocument& prepared, TL::GraphemeIndex g) { + return prepared.graphemeAdvancePrefix[g + 1] - prepared.graphemeAdvancePrefix[g]; +} + +std::vector visualGraphemes(const TL::PreparedDocument& prepared, + const TL::DocumentLayout& output, const TL::LayoutLine& line) { + std::vector result; + for (TL::RunIndex layoutRunIndex = line.glyphRunRange.min; layoutRunIndex < line.glyphRunRange.max; + ++layoutRunIndex) { + const TL::LayoutGlyphRun& layoutRun = output.glyphRuns[layoutRunIndex]; + const auto preparedRun = std::ranges::find_if(prepared.glyphRuns, [&](const TL::GlyphRun& run) { + return layoutRun.glyphRange.min >= run.glyphRange.min && + layoutRun.glyphRange.max <= run.glyphRange.max; + }); + REQUIRE(preparedRun != prepared.glyphRuns.end()); + for (TL::GlyphIndex glyphIndex = layoutRun.glyphRange.min; glyphIndex < layoutRun.glyphRange.max; + ++glyphIndex) { + result.push_back(preparedRun->graphemeRange.min + prepared.glyphs[glyphIndex].graphemeIndex); + } + } + return result; +} + +std::vector selectionRects(const TL::PreparedDocument& prepared, + const TL::DocumentLayout& layout, TL::GraphemeRange selection) { + std::vector result; + TL::selectionRects(prepared, layout, selection, [&](const TL::SelectionRect& rect) { + result.push_back(rect); + }); + return result; +} + +} // namespace + +TEST_CASE("caretPosition resolves LTR boundaries and paragraph extremes", "[layout2][queries][caret]") { + const auto prepared = preparedDocument( + U"abc", std::vector(3, kNoBreak), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(20.0f)), advance(TL::fromFloat(30.0f)) }); + REQUIRE(prepared.graphemes.size() == 3); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + + const TL::CaretPosition start = TL::caretPosition(prepared, layout, { 0, TL::CaretAffinity::Upstream }); + const TL::CaretPosition middle = + TL::caretPosition(prepared, layout, { 2, TL::CaretAffinity::Downstream }); + const TL::CaretPosition end = TL::caretPosition(prepared, layout, { 3, TL::CaretAffinity::Downstream }); + + REQUIRE(start.x == TL::kZero); + REQUIRE(start.line == 0); + REQUIRE(start.level == 0); + REQUIRE(middle.x == TL::fromFloat(30.0f)); + REQUIRE(middle.line == 0); + REQUIRE(middle.level == 0); + REQUIRE(end.x == TL::fromFloat(60.0f)); + REQUIRE(end.line == 0); + REQUIRE(end.level == 0); +} + +TEST_CASE("caretPosition uses affinity at a BiDi run boundary", "[layout2][queries][caret][bidi]") { + const std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 2 }, .graphemeRange = { 0, 2 }, .level = 0 }, + TL::GlyphRun{ .codepointRange = { 2, 4 }, .graphemeRange = { 2, 4 }, .level = 1 }, + }; + const auto prepared = preparedDocument(U"abcd", std::vector(4, kNoBreak), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }, + runs); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + + const TL::CaretPosition upstream = + TL::caretPosition(prepared, layout, { 2, TL::CaretAffinity::Upstream }); + const TL::CaretPosition downstream = + TL::caretPosition(prepared, layout, { 2, TL::CaretAffinity::Downstream }); + const TL::CaretPosition insideRtl = + TL::caretPosition(prepared, layout, { 3, TL::CaretAffinity::Downstream }); + + REQUIRE(upstream.x == TL::fromFloat(20.0f)); + REQUIRE(upstream.level == 0); + REQUIRE(downstream.x == TL::fromFloat(40.0f)); + REQUIRE(downstream.level == 1); + REQUIRE(insideRtl.x == TL::fromFloat(30.0f)); + REQUIRE(insideRtl.level == 1); +} + +TEST_CASE("caretPosition uses affinity at a soft wrap", "[layout2][queries][caret][wrap]") { + std::vector opportunities(5, kNoBreak); + opportunities[2] = TL::LineBreakKind::AllowBreak; + const auto prepared = preparedDocument(U"ab cd", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(5.0f), true), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(25.0f)); + + const TL::CaretPosition upstream = + TL::caretPosition(prepared, layout, { 3, TL::CaretAffinity::Upstream }); + const TL::CaretPosition downstream = + TL::caretPosition(prepared, layout, { 3, TL::CaretAffinity::Downstream }); + + REQUIRE(upstream.line == 0); + REQUIRE(upstream.x == TL::fromFloat(25.0f)); + REQUIRE(downstream.line == 1); + REQUIRE(downstream.x == TL::kZero); +} + +TEST_CASE("caretPosition uses resolved tab width and handles an empty paragraph", + "[layout2][queries][caret][tabs]") { + const std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 1 }, .graphemeRange = { 0, 1 } }, + TL::GlyphRun{ .codepointRange = { 1, 2 }, .graphemeRange = { 1, 2 } }, + TL::GlyphRun{ .codepointRange = { 2, 3 }, .graphemeRange = { 2, 3 } }, + }; + const auto prepared = + preparedDocument(U"a\tb", std::vector(3, kNoBreak), + { advance(TL::fromFloat(6.0f)), tab(), advance(TL::fromFloat(4.0f)) }, runs); + const TL::DocumentLayout layout = TL::layoutPreparedDocument( + prepared, {}, TL::fromFloat(100.0f), + TL::TabStops{ .firstStop = TL::fromFloat(10.0f), .interval = TL::fromFloat(10.0f) }); + + REQUIRE(TL::caretPosition(prepared, layout, { 1, TL::CaretAffinity::Downstream }).x == + TL::fromFloat(6.0f)); + REQUIRE(TL::caretPosition(prepared, layout, { 2, TL::CaretAffinity::Upstream }).x == + TL::fromFloat(10.0f)); + + const auto empty = preparedDocument(U"", {}, {}); + const TL::DocumentLayout emptyLayout = TL::layoutPreparedDocument(empty, {}, TL::fromFloat(100.0f)); + const TL::CaretPosition emptyCaret = + TL::caretPosition(empty, emptyLayout, { 0, TL::CaretAffinity::Downstream }); + REQUIRE(emptyCaret.x == TL::kZero); + REQUIRE(emptyCaret.line == 0); + REQUIRE(emptyCaret.level == 0); +} + +TEST_CASE("hitTestPoint rounds to nearest LTR boundary and clamps horizontally", + "[layout2][queries][hitTest]") { + const auto prepared = preparedDocument( + U"abc", std::vector(3, kNoBreak), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(20.0f)), advance(TL::fromFloat(30.0f)) }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + + const TL::CaretIndex before = TL::hitTestPoint(prepared, layout, TL::fromFloat(-10.0f), TL::kZero); + const TL::CaretIndex leftOfMidpoint = TL::hitTestPoint(prepared, layout, TL::fromFloat(19.0f), TL::kZero); + const TL::CaretIndex rightOfMidpoint = + TL::hitTestPoint(prepared, layout, TL::fromFloat(21.0f), TL::kZero); + const TL::CaretIndex after = TL::hitTestPoint(prepared, layout, TL::fromFloat(100.0f), TL::kZero); + + REQUIRE(before.grapheme == 0); + REQUIRE(before.affinity == TL::CaretAffinity::Downstream); + REQUIRE(leftOfMidpoint.grapheme == 1); + REQUIRE(rightOfMidpoint.grapheme == 2); + REQUIRE(after.grapheme == 3); + REQUIRE(after.affinity == TL::CaretAffinity::Upstream); +} + +TEST_CASE("hitTestPoint returns the correct side of a BiDi boundary", "[layout2][queries][hitTest][bidi]") { + const std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 2 }, .graphemeRange = { 0, 2 }, .level = 0 }, + TL::GlyphRun{ .codepointRange = { 2, 4 }, .graphemeRange = { 2, 4 }, .level = 1 }, + }; + const auto prepared = preparedDocument(U"abcd", std::vector(4, kNoBreak), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }, + runs); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + + const TL::CaretIndex upstream = TL::hitTestPoint(prepared, layout, TL::fromFloat(19.0f), TL::kZero); + const TL::CaretIndex downstream = TL::hitTestPoint(prepared, layout, TL::fromFloat(39.0f), TL::kZero); + + REQUIRE(upstream.grapheme == 2); + REQUIRE(upstream.affinity == TL::CaretAffinity::Upstream); + REQUIRE(downstream.grapheme == 2); + REQUIRE(downstream.affinity == TL::CaretAffinity::Downstream); + REQUIRE(TL::caretPosition(prepared, layout, upstream).x == TL::fromFloat(20.0f)); + REQUIRE(TL::caretPosition(prepared, layout, downstream).x == TL::fromFloat(40.0f)); +} + +TEST_CASE("hitTestPoint chooses and clamps vertical lines including leading gaps", + "[layout2][queries][hitTest][wrap]") { + std::vector opportunities(4, kNoBreak); + opportunities[1] = TL::LineBreakKind::MustBreak; + std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 4 }, + .graphemeRange = { 0, 4 }, + .metrics = { TL::fromFloat(5.0f), TL::fromFloat(-3.0f), TL::fromFloat(4.0f) } }, + }; + const auto prepared = preparedDocument(U"abcd", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }, + std::move(runs)); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + REQUIRE(layout.lines.size() == 2); + + const TL::CaretIndex above = TL::hitTestPoint(prepared, layout, TL::kZero, TL::fromFloat(-100.0f)); + const TL::CaretIndex gapNearFirst = TL::hitTestPoint(prepared, layout, TL::kZero, TL::fromFloat(9.0f)); + const TL::CaretIndex gapNearSecond = TL::hitTestPoint(prepared, layout, TL::kZero, TL::fromFloat(10.0f)); + const TL::CaretIndex below = + TL::hitTestPoint(prepared, layout, TL::fromFloat(100.0f), TL::fromFloat(100.0f)); + + REQUIRE(above.grapheme == 0); + REQUIRE(gapNearFirst.grapheme == 0); + REQUIRE(gapNearSecond.grapheme == 0); + REQUIRE(gapNearSecond.affinity == TL::CaretAffinity::Downstream); + REQUIRE(below.grapheme == 4); +} + +TEST_CASE("hitTestPoint handles tabs and an empty paragraph", "[layout2][queries][hitTest][tabs]") { + const std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 1 }, .graphemeRange = { 0, 1 } }, + TL::GlyphRun{ .codepointRange = { 1, 2 }, .graphemeRange = { 1, 2 } }, + TL::GlyphRun{ .codepointRange = { 2, 3 }, .graphemeRange = { 2, 3 } }, + }; + const auto prepared = + preparedDocument(U"a\tb", std::vector(3, kNoBreak), + { advance(TL::fromFloat(6.0f)), tab(), advance(TL::fromFloat(4.0f)) }, runs); + const TL::DocumentLayout layout = TL::layoutPreparedDocument( + prepared, {}, TL::fromFloat(100.0f), + TL::TabStops{ .firstStop = TL::fromFloat(10.0f), .interval = TL::fromFloat(10.0f) }); + REQUIRE(TL::hitTestPoint(prepared, layout, TL::fromFloat(9.0f), TL::kZero).grapheme == 2); + + const auto empty = preparedDocument(U"", {}, {}); + const TL::DocumentLayout emptyLayout = TL::layoutPreparedDocument(empty, {}, TL::fromFloat(100.0f)); + const TL::CaretIndex emptyHit = + TL::hitTestPoint(empty, emptyLayout, TL::fromFloat(50.0f), TL::fromFloat(50.0f)); + REQUIRE(emptyHit.grapheme == 0); + REQUIRE(emptyHit.affinity == TL::CaretAffinity::Downstream); +} + +TEST_CASE("selectionRects emits partial and multiline LTR rectangles", "[layout2][queries][selection]") { + std::vector opportunities(5, kNoBreak); + opportunities[2] = TL::LineBreakKind::AllowBreak; + const auto prepared = preparedDocument(U"ab cd", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(5.0f), true), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(25.0f)); + + const std::vector rects = selectionRects(prepared, layout, { 1, 4 }); + REQUIRE(rects.size() == 2); + REQUIRE(rects[0].line == 0); + REQUIRE(rects[0].x0 == TL::fromFloat(10.0f)); + REQUIRE(rects[0].x1 == TL::fromFloat(25.0f)); + REQUIRE(rects[1].line == 1); + REQUIRE(rects[1].x0 == TL::kZero); + REQUIRE(rects[1].x1 == TL::fromFloat(10.0f)); +} + +TEST_CASE("selectionRects emits one visual-order rectangle per intersected BiDi run", + "[layout2][queries][selection][bidi]") { + const std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 2 }, .graphemeRange = { 0, 2 }, .level = 0 }, + TL::GlyphRun{ .codepointRange = { 2, 4 }, .graphemeRange = { 2, 4 }, .level = 1 }, + TL::GlyphRun{ .codepointRange = { 4, 6 }, .graphemeRange = { 4, 6 }, .level = 0 }, + }; + const auto prepared = preparedDocument(U"abcdef", std::vector(6, kNoBreak), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }, + runs); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + + const std::vector rects = selectionRects(prepared, layout, { 1, 5 }); + REQUIRE(rects.size() == 3); + REQUIRE(rects[0].line == 0); + REQUIRE(rects[0].x0 == TL::fromFloat(10.0f)); + REQUIRE(rects[0].x1 == TL::fromFloat(20.0f)); + REQUIRE(rects[1].x0 == TL::fromFloat(20.0f)); + REQUIRE(rects[1].x1 == TL::fromFloat(40.0f)); + REQUIRE(rects[2].x0 == TL::fromFloat(40.0f)); + REQUIRE(rects[2].x1 == TL::fromFloat(50.0f)); +} + +TEST_CASE("selectionRects follows reordered visual runs", "[layout2][queries][selection][bidi]") { + const std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 1 }, .graphemeRange = { 0, 1 }, .level = 0 }, + TL::GlyphRun{ .codepointRange = { 1, 2 }, .graphemeRange = { 1, 2 }, .level = 1 }, + TL::GlyphRun{ .codepointRange = { 2, 3 }, .graphemeRange = { 2, 3 }, .level = 2 }, + }; + const auto prepared = preparedDocument( + U"abc", std::vector(3, kNoBreak), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(20.0f)), advance(TL::fromFloat(30.0f)) }, + runs); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + + const std::vector rects = selectionRects(prepared, layout, { 1, 3 }); + REQUIRE(rects.size() == 2); + REQUIRE(rects[0].x0 == TL::fromFloat(10.0f)); + REQUIRE(rects[0].x1 == TL::fromFloat(40.0f)); + REQUIRE(rects[1].x0 == TL::fromFloat(40.0f)); + REQUIRE(rects[1].x1 == TL::fromFloat(60.0f)); +} + +TEST_CASE("selectionRects uses resolved tab geometry", "[layout2][queries][selection][tabs]") { + const std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 1 }, .graphemeRange = { 0, 1 } }, + TL::GlyphRun{ .codepointRange = { 1, 2 }, .graphemeRange = { 1, 2 } }, + TL::GlyphRun{ .codepointRange = { 2, 3 }, .graphemeRange = { 2, 3 } }, + }; + const auto prepared = + preparedDocument(U"a\tb", std::vector(3, kNoBreak), + { advance(TL::fromFloat(6.0f)), tab(), advance(TL::fromFloat(4.0f)) }, runs); + const TL::DocumentLayout layout = TL::layoutPreparedDocument( + prepared, {}, TL::fromFloat(100.0f), + TL::TabStops{ .firstStop = TL::fromFloat(10.0f), .interval = TL::fromFloat(10.0f) }); + + const std::vector rects = selectionRects(prepared, layout, { 1, 2 }); + REQUIRE(rects.size() == 1); + REQUIRE(rects[0].x0 == TL::fromFloat(6.0f)); + REQUIRE(rects[0].x1 == TL::fromFloat(10.0f)); +} + +TEST_CASE("selectionRects ignores empty and invalid selections", "[layout2][queries][selection][edge]") { + const auto prepared = preparedDocument( + U"abc", std::vector(3, kNoBreak), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + + REQUIRE(selectionRects(prepared, layout, { 1, 1 }).empty()); + REQUIRE(selectionRects(prepared, layout, { 2, 1 }).empty()); + REQUIRE(selectionRects(prepared, layout, { 0, 4 }).empty()); + REQUIRE(selectionRects(prepared, layout, { 4, 4 }).empty()); + + const auto empty = preparedDocument(U"", {}, {}); + const TL::DocumentLayout emptyLayout = TL::layoutPreparedDocument(empty, {}, TL::fromFloat(100.0f)); + REQUIRE(selectionRects(empty, emptyLayout, { 0, 0 }).empty()); +} + +TEST_CASE("hitTestPoint clamps horizontally and vertically outside every line of a wrapped paragraph", + "[layout2][queries][hitTest][edge]") { + // Mandatory break after grapheme index 2 (before grapheme 3) splits "abcdef" into two lines of + // three graphemes each, 10 units wide per grapheme (30 units per line). + std::vector opportunities(6, kNoBreak); + opportunities[2] = TL::LineBreakKind::MustBreak; + std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 6 }, + .graphemeRange = { 0, 6 }, + .metrics = { TL::fromFloat(5.0f), TL::fromFloat(3.0f), TL::kZero } }, + }; + const auto prepared = preparedDocument(U"abcdef", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }, + std::move(runs)); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(1000.0f)); + REQUIRE(layout.lines.size() == 2); + REQUIRE(layout.lines[1].baselineY > layout.lines[0].baselineY); + + // Far above and to the left of the whole document clamps to the very first boundary. + const TL::CaretIndex topLeft = + TL::hitTestPoint(prepared, layout, TL::fromFloat(-1000.0f), TL::fromFloat(-1000.0f)); + REQUIRE(topLeft.grapheme == 0); + REQUIRE(topLeft.affinity == TL::CaretAffinity::Downstream); + + // Far below and to the right of the whole document clamps to the very last boundary. + const TL::CaretIndex bottomRight = + TL::hitTestPoint(prepared, layout, TL::fromFloat(1000.0f), TL::fromFloat(1000.0f)); + REQUIRE(bottomRight.grapheme == 6); + REQUIRE(bottomRight.affinity == TL::CaretAffinity::Upstream); + + // Far to the right but vertically within the first line clamps horizontally to that line's own + // last boundary rather than falling through to the second line. + const TL::CaretIndex rightOfFirstLine = + TL::hitTestPoint(prepared, layout, TL::fromFloat(1000.0f), layout.lines[0].baselineY); + REQUIRE(rightOfFirstLine.grapheme == 3); + REQUIRE(rightOfFirstLine.affinity == TL::CaretAffinity::Upstream); + + // Far to the left but vertically within the second line clamps horizontally to that line's own + // first boundary rather than snapping back to the first line. + const TL::CaretIndex leftOfSecondLine = + TL::hitTestPoint(prepared, layout, TL::fromFloat(-1000.0f), layout.lines[1].baselineY); + REQUIRE(leftOfSecondLine.grapheme == 3); + REQUIRE(leftOfSecondLine.affinity == TL::CaretAffinity::Downstream); +} + +TEST_CASE("center alignment places every line with spaces at equal border distances", + "[layout2][alignment]") { + const auto prepared = preparedDocument( + U"ab cdef", + { kNoBreak, kNoBreak, TL::LineBreakKind::AllowBreak, kNoBreak, kNoBreak, kNoBreak, kNoBreak }, + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(5.0f), true), + advance(TL::fromFloat(15.0f)), advance(TL::fromFloat(15.0f)), advance(TL::fromFloat(15.0f)), + advance(TL::fromFloat(15.0f)) }); + const TL::LayoutUnit maxLineWidth = TL::fromFloat(70.0f); + const TL::DocumentLayout output = + TL::layoutPreparedDocument(prepared, std::array{ TL::TextAlignment::Center }, maxLineWidth); + + REQUIRE(output.lines.size() == 2); + for (const TL::LayoutLine& line : output.lines) { + const TL::LayoutUnit leftBorderDistance = line.originX; + const TL::LayoutUnit rightBorderDistance = maxLineWidth - (line.originX + line.trimmedWidth); + REQUIRE(leftBorderDistance == rightBorderDistance); + } + REQUIRE(output.lines[0].originX == TL::fromFloat(25.0f)); + REQUIRE(output.lines[1].originX == TL::fromFloat(5.0f)); +} + +TEST_CASE("layout applies the requested font line height between lines", "[layout2][line-height]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + font.lineHeight = TL::fromFloat(40.0f); + const std::array fonts{ &font }; + const std::u32string text = U"a b"; + const std::array boundaries{ 0, static_cast(text.size()) }; + + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + REQUIRE(prepared.graphemes.size() == 3); + + const TL::LayoutUnit firstLineWidth = graphemeAdvance(prepared, 0) + graphemeAdvance(prepared, 1); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, firstLineWidth); + + REQUIRE(output.lines.size() == 2); + REQUIRE(output.lines[1].baselineY - output.lines[0].baselineY == TL::fromFloat(40.0f)); +} + +TEST_CASE("explicit line height remains the complete line box even below font metrics", + "[layout2][line-height]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + font.fontSize = TL::fromFloat(24.0f); + font.lineHeight = TL::fromFloat(12.0f); + const std::array fonts{ &font }; + const std::u32string text = U"ab\ncd"; + const std::array boundaries{ 0, static_cast(text.size()) }; + + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }); + REQUIRE_FALSE(prepared.glyphRuns.empty()); + + const TL::VerticalMetrics metrics = prepared.glyphRuns.front().metrics; + const TL::LayoutUnit contentHeight = metrics.ascent - metrics.descent; + REQUIRE(metrics.lineGap == TL::fromFloat(12.0f) - contentHeight); + + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(200.0f)); + REQUIRE(output.lines.size() == 2); + REQUIRE(output.lines[1].baselineY - output.lines[0].baselineY == TL::fromFloat(12.0f)); + REQUIRE(TL::toFloat(output.verticalTextBounds.max - output.verticalTextBounds.min) == + Catch::Approx(24.0f).margin(2.0f / 64.0f)); +} + +TEST_CASE("GreedyLineBreaker single run that fits emits one TextEnd line", "[layout2][greedyLineBreaker]") { + std::vector opportunities(3, kNoBreak); + + constexpr TL::LayoutUnit kMaxLineWidth = TL::fromFloat(100.0f); + + const auto prepared = preparedDocument( + U"abc", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, kMaxLineWidth); + const auto& lines = output.lines; + REQUIRE(output.lines.size() == 1); + + REQUIRE(lines.size() == 1); + REQUIRE(lines[0].endKind == TL::LineEndKind::ParagraphEnd); + REQUIRE(lines[0].width == TL::fromFloat(30.0f)); + REQUIRE(lines[0].trimmedWidth == TL::fromFloat(30.0f)); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 3 }); + REQUIRE(lines[0].codepointRange == TL::CodepointRange{ 0, 3 }); +} + +TEST_CASE("GreedyLineBreaker soft-wraps at the last safe candidate and trims trailing whitespace", + "[layout2][greedyLineBreaker]") { + // "ab cd": a soft break opportunity sits right after the space. + std::vector opportunities(5, kNoBreak); + opportunities[2] = TL::LineBreakKind::AllowBreak; // boundary before 'c' (grapheme index 3) + + const auto prepared = preparedDocument(U"ab cd", std::move(opportunities), + { + advance(TL::fromFloat(10.0f)), // 'a' + advance(TL::fromFloat(10.0f)), // 'b' + advance(TL::fromFloat(5.0f), /*trimmable=*/true), // ' ' + advance(TL::fromFloat(10.0f)), // 'c' + advance(TL::fromFloat(10.0f)), // 'd' + }); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(25.0f)); + const auto& lines = output.lines; + + REQUIRE(lines.size() == 2); + + REQUIRE(lines[0].endKind == TL::LineEndKind::SoftWrap); + REQUIRE(lines[0].width == TL::fromFloat(25.0f)); + REQUIRE(lines[0].trimmedWidth == TL::fromFloat(20.0f)); // trailing space excluded + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 3 }); + + REQUIRE(lines[1].endKind == TL::LineEndKind::ParagraphEnd); + REQUIRE(lines[1].width == TL::fromFloat(20.0f)); + REQUIRE(lines[1].trimmedWidth == TL::fromFloat(20.0f)); + REQUIRE(lines[1].graphemeRange == TL::GraphemeRange{ 3, 5 }); + + // Alignment is Start (LTR) -> originX is 0 for both lines. + REQUIRE(output.horizontalTextBounds == TL::LayoutRange{ TL::kZero, TL::fromFloat(25.0f) }); + REQUIRE(output.horizontalTrimmedTextBounds == TL::LayoutRange{ TL::kZero, TL::fromFloat(20.0f) }); + REQUIRE(output.verticalTextBounds == TL::LayoutRange{ TL::kZero, TL::kZero }); +} + +TEST_CASE("GreedyLineBreaker expands tabs to repeating paragraph-relative stops", + "[layout2][greedyLineBreaker][tabs]") { + // "a\tb\t": tabs break after themselves (BA) and advance to stops 10, 20, ... . + std::vector opportunities(4, kNoBreak); + opportunities[1] = TL::LineBreakKind::AllowBreak; + opportunities[3] = TL::LineBreakKind::AllowBreak; + const auto prepared = + preparedDocument(U"a\tb\t", std::move(opportunities), + { advance(TL::fromFloat(6.0f)), tab(), advance(TL::fromFloat(4.0f)), tab() }); + + const TL::TabStops tabStops{ + .firstStop = TL::fromFloat(10.0f), + .interval = TL::fromFloat(10.0f), + }; + const TL::DocumentLayout output = + TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f), tabStops); + + REQUIRE(output.lines.size() == 1); + REQUIRE(output.lines[0].width == TL::fromFloat(20.0f)); + REQUIRE(output.lines[0].trimmedWidth == TL::fromFloat(14.0f)); +} + +TEST_CASE("GreedyLineBreaker recalculates a carried tab after wrapping", + "[layout2][greedyLineBreaker][tabs]") { + // The tab overflows the first line, which is allowed to end after the preceding space. + // On the next line it must be recomputed from the new line origin rather than retain its + // original 5-unit advance. + std::vector opportunities(4, kNoBreak); + opportunities[1] = TL::LineBreakKind::AllowBreak; + opportunities[2] = TL::LineBreakKind::NoBreak; // the tab may not become a later candidate + const auto prepared = + preparedDocument(U"a \tb", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(5.0f), true), tab(), + advance(TL::fromFloat(5.0f)) }); + + const TL::TabStops tabStops{ + .firstStop = TL::fromFloat(20.0f), + .interval = TL::fromFloat(20.0f), + }; + const TL::DocumentLayout output = + TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(19.0f), tabStops); + + REQUIRE(output.lines.size() == 2); + REQUIRE(output.lines[0].graphemeRange == TL::GraphemeRange{ 0, 2 }); + REQUIRE(output.lines[0].width == TL::fromFloat(15.0f)); + REQUIRE(output.lines[0].trimmedWidth == TL::fromFloat(10.0f)); + REQUIRE(output.lines[1].graphemeRange == TL::GraphemeRange{ 2, 4 }); + REQUIRE(output.lines[1].width == TL::fromFloat(25.0f)); +} + +TEST_CASE("GreedyLineBreaker mandatory break splits the line unconditionally", + "[layout2][greedyLineBreaker]") { + std::vector opportunities(4, kNoBreak); + opportunities[1] = TL::LineBreakKind::MustBreak; // boundary before 'c' (grapheme index 2) + const auto prepared = preparedDocument(U"abcd", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(1000.0f)); + const auto& lines = output.lines; + REQUIRE(output.lines.size() == 2); + REQUIRE(lines.size() == 2); + REQUIRE(lines[0].endKind == TL::LineEndKind::MandatoryBreak); + REQUIRE(lines[0].width == TL::fromFloat(20.0f)); + REQUIRE(lines[0].trimmedWidth == TL::fromFloat(20.0f)); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 2 }); + + REQUIRE(lines[1].endKind == TL::LineEndKind::ParagraphEnd); + REQUIRE(lines[1].width == TL::fromFloat(20.0f)); + REQUIRE(lines[1].graphemeRange == TL::GraphemeRange{ 2, 4 }); +} + +TEST_CASE("GreedyLineBreaker infinite width ignores optional breaks but honors mandatory breaks", + "[layout2][greedyLineBreaker]") { + std::vector opportunities(6, kNoBreak); + opportunities[1] = TL::LineBreakKind::AllowBreak; + opportunities[3] = TL::LineBreakKind::MustBreak; + const auto prepared = preparedDocument(U"abcdef", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }); + + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, TL::kInfinity); + + REQUIRE(output.lines.size() == 2); + REQUIRE(output.lines[0].endKind == TL::LineEndKind::MandatoryBreak); + REQUIRE(output.lines[0].graphemeRange == TL::GraphemeRange{ 0, 4 }); + REQUIRE(output.lines[0].width == TL::fromFloat(40.0f)); + REQUIRE(output.lines[1].endKind == TL::LineEndKind::ParagraphEnd); + REQUIRE(output.lines[1].graphemeRange == TL::GraphemeRange{ 4, 6 }); + REQUIRE(output.lines[1].width == TL::fromFloat(20.0f)); +} + +TEST_CASE("GreedyLineBreaker merges slices from multiple runs on the same line", + "[layout2][greedyLineBreaker]") { + std::vector opportunities(4, kNoBreak); + const std::vector runs{ + TL::GlyphRun{ .codepointRange = { 0, 2 }, .graphemeRange = { 0, 2 }, .level = 0 }, + TL::GlyphRun{ .codepointRange = { 2, 4 }, .graphemeRange = { 2, 4 }, .level = 1 }, + }; + const auto prepared = preparedDocument(U"abcd", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }, + runs); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(1000.0f)); + const auto& lines = output.lines; + + REQUIRE(lines.size() == 1); + REQUIRE(lines[0].width == TL::fromFloat(40.0f)); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 4 }); + REQUIRE(lines[0].glyphRunRange == TL::RunRange{ 0, 2 }); + REQUIRE(output.glyphRuns.size() == 2); +} + +TEST_CASE("GreedyLineBreaker skips unsafe-to-break candidates and overflows until a safe one", + "[layout2][greedyLineBreaker]") { + // "abcd": an AllowBreak opportunity exists before 'c', but it is unsafe to break there + // (as if HarfBuzz had reported it would require reshaping), so the line must keep growing + // past maxLineWidth until the safe opportunity before 'd' is reached. + std::vector opportunities(4, kNoBreak); + opportunities[1] = TL::LineBreakKind::NoBreak; // boundary before 'c' -- HB unsafe + opportunities[2] = TL::LineBreakKind::AllowBreak; // boundary before 'd' -- safe + const auto prepared = preparedDocument(U"abcd", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(15.0f)); + const auto& lines = output.lines; + REQUIRE(output.lines.size() == 2); + + REQUIRE(lines.size() == 2); + // Overflows past maxLineWidth (15) up to 30 before the safe candidate before 'd' is used. + REQUIRE(lines[0].endKind == TL::LineEndKind::SoftWrap); + REQUIRE(lines[0].width == TL::fromFloat(30.0f)); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 3 }); // 'a','b','c' + + REQUIRE(lines[1].endKind == TL::LineEndKind::ParagraphEnd); + REQUIRE(lines[1].width == TL::fromFloat(10.0f)); + REQUIRE(lines[1].graphemeRange == TL::GraphemeRange{ 3, 4 }); // 'd' +} + +TEST_CASE("GreedyLineBreaker finish() splits off a trailing mandatory break as its own line", + "[layout2][greedyLineBreaker]") { + // The paragraph's very last codepoint is itself a mandatory break; addRun() never sees a + // following grapheme to act on it, so finish() must detect and split it off before flushing + // the (empty) remainder. + std::vector opportunities(2, kNoBreak); + opportunities[1] = TL::LineBreakKind::MustBreak; // trailing mandatory break, after 'b' + const auto prepared = preparedDocument(U"ab", std::move(opportunities), + { advance(TL::fromFloat(10.0f)), advance(TL::fromFloat(10.0f)) }); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(1000.0f)); + const auto& lines = output.lines; + REQUIRE(output.lines.size() == 2); + + REQUIRE(lines.size() == 2); + REQUIRE(lines[0].endKind == TL::LineEndKind::MandatoryBreak); + REQUIRE(lines[0].width == TL::fromFloat(20.0f)); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 2 }); + + REQUIRE(lines[1].endKind == TL::LineEndKind::ParagraphEnd); + REQUIRE(lines[1].width == TL::fromFloat(0.0f)); + REQUIRE(lines[1].trimmedWidth == TL::fromFloat(0.0f)); + REQUIRE(lines[1].graphemeRange == TL::GraphemeRange{ 2, 2 }); +} + +TEST_CASE("GreedyLineBreaker recomputes trailing whitespace after a soft wrap", + "[layout2][greedyLineBreaker]") { + std::vector opportunities(5, kNoBreak); + opportunities[1] = TL::LineBreakKind::AllowBreak; + const auto prepared = preparedDocument(U"a b ", std::move(opportunities), + { + advance(TL::fromFloat(20.0f)), + advance(TL::fromFloat(5.0f), true), + advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(10.0f)), + advance(TL::fromFloat(5.0f), true), + }); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(30.0f)); + const auto& lines = output.lines; + REQUIRE(output.lines.size() == 2); + + REQUIRE(lines.size() == 2); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 2 }); + REQUIRE(lines[0].trimmedWidth == TL::fromFloat(20.0f)); + REQUIRE(lines[1].graphemeRange == TL::GraphemeRange{ 2, 5 }); + REQUIRE(lines[1].width == TL::fromFloat(25.0f)); + REQUIRE(lines[1].trimmedWidth == TL::fromFloat(20.0f)); +} + +TEST_CASE("layout places each U+2028 line separator segment on its own line", + "[layout2][prepared][line-separator]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + const std::array fonts{ &font }; + const std::u32string text = U"ab\u2028cd\u2028e"; + const std::array boundaries{ 0, static_cast(text.size()) }; + + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + REQUIRE(prepared.graphemes.size() == text.size()); + REQUIRE(prepared.breakOpportunities.size() == text.size()); + // U+2028 is a mandatory break after itself. + REQUIRE(prepared.breakOpportunities[2] == TL::LineBreakKind::MustBreak); + REQUIRE(prepared.breakOpportunities[5] == TL::LineBreakKind::MustBreak); + + const auto output = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(1000.0f)); + const auto& lines = output.lines; + + REQUIRE(lines.size() == 3); + + REQUIRE(lines[0].endKind == TL::LineEndKind::MandatoryBreak); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 3 }); + REQUIRE(lines[0].codepointRange == TL::CodepointRange{ 0, 3 }); + REQUIRE(lines[0].trimmedWidth == prepared.graphemeAdvancePrefix[2] - prepared.graphemeAdvancePrefix[0]); + + REQUIRE(lines[1].endKind == TL::LineEndKind::MandatoryBreak); + REQUIRE(lines[1].graphemeRange == TL::GraphemeRange{ 3, 6 }); + REQUIRE(lines[1].codepointRange == TL::CodepointRange{ 3, 6 }); + REQUIRE(lines[1].trimmedWidth == prepared.graphemeAdvancePrefix[5] - prepared.graphemeAdvancePrefix[3]); + + REQUIRE(lines[2].endKind == TL::LineEndKind::ParagraphEnd); + REQUIRE(lines[2].graphemeRange == TL::GraphemeRange{ 6, 7 }); + REQUIRE(lines[2].codepointRange == TL::CodepointRange{ 6, 7 }); + REQUIRE(lines[2].trimmedWidth == graphemeAdvance(prepared, 6)); + + // All lines start at the left margin and baselines advance line by line. + REQUIRE(lines[0].originX == TL::kZero); + REQUIRE(lines[1].originX == TL::kZero); + REQUIRE(lines[2].originX == TL::kZero); + REQUIRE(lines[1].baselineY > lines[0].baselineY); + REQUIRE(lines[2].baselineY > lines[1].baselineY); +} + +TEST_CASE("prepareDocument produces cacheable glyph and run buffers", "[layout2][prepared]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + const std::array fonts{ &font }; + const std::u32string text = U"Hello world"; + const std::array boundaries{ 0, static_cast(text.size()) }; + + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, scriptTag("Latn"), "en"); + + REQUIRE(prepared.graphemeMap.graphemeCount() == text.size()); + REQUIRE(prepared.graphemes.size() == text.size()); + REQUIRE(prepared.graphemeAdvancePrefix.size() == prepared.graphemes.size() + 1); + REQUIRE(prepared.graphemeAdvancePrefix.front() == TL::kZero); + REQUIRE(prepared.breakOpportunities.size() == text.size()); + REQUIRE_FALSE(prepared.glyphs.empty()); + REQUIRE_FALSE(prepared.glyphRuns.empty()); + + TL::GlyphIndex nextGlyph = 0; + TL::GraphemeIndex nextGrapheme = 0; + for (const TL::GlyphRun& run : prepared.glyphRuns) { + REQUIRE(run.glyphRange.min == nextGlyph); + REQUIRE(run.graphemeRange.min == nextGrapheme); + REQUIRE(run.fontHandle); + nextGlyph = run.glyphRange.max; + nextGrapheme = run.graphemeRange.max; + } + REQUIRE(nextGlyph == prepared.glyphs.size()); + REQUIRE(nextGrapheme == prepared.graphemes.size()); + + for (TL::GraphemeIndex g = 0; g < prepared.graphemes.size(); ++g) { + REQUIRE(graphemeAdvance(prepared, g) > TL::kZero); + } + REQUIRE(prepared.graphemes[5].trimmableWhitespace); +} + +TEST_CASE("prepared paragraph layout exposes renderable glyph run slices", "[layout2][prepared]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + const std::array fonts{ &font }; + const std::u32string text = U"ab cd"; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, scriptTag("Latn"), "en"); + + const TL::LayoutUnit firstWordWidth = graphemeAdvance(prepared, 0) + graphemeAdvance(prepared, 1); + const TL::LayoutUnit firstLineWidth = firstWordWidth + graphemeAdvance(prepared, 2); + const TL::DocumentLayout output = TL::layoutPreparedDocument(prepared, {}, firstLineWidth); + const auto& lines = output.lines; + + REQUIRE(lines.size() == 2); + REQUIRE(lines[0].glyphRunRange.distance() == 1); + REQUIRE(lines[1].glyphRunRange.distance() == 1); + REQUIRE(output.glyphRuns.size() == 2); + + const TL::LayoutGlyphRun& first = output.glyphRuns[lines[0].glyphRunRange.min]; + const TL::LayoutGlyphRun& second = output.glyphRuns[lines[1].glyphRunRange.min]; + REQUIRE(first.preparedRunIndex == 0); + REQUIRE(second.preparedRunIndex == 0); + REQUIRE(prepared.glyphRuns[0].fontHandle); + REQUIRE(first.glyphRange.min == prepared.glyphRuns[0].glyphRange.min); + REQUIRE(first.glyphRange.max <= second.glyphRange.min); + REQUIRE(second.glyphRange.max == prepared.glyphRuns[0].glyphRange.max); + REQUIRE(first.xOffset == lines[0].originX); + REQUIRE(first.yOffset == lines[0].baselineY); + REQUIRE(second.xOffset < lines[1].originX); + REQUIRE(second.yOffset == lines[1].baselineY); +} + +TEST_CASE("prepared paragraph wraps mixed LTR and RTL text in visual glyph order", + "[layout2][prepared][bidi]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + const std::array fonts{ &font }; + const std::u32string text = U"abc \u05D0\u05D1\u05D2 def"; + const std::array boundaries{ 0, static_cast(text.size()) }; + const auto prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + + TL::LayoutUnit firstLineWidth = TL::kZero; + for (TL::GraphemeIndex g = 0; g < 8; ++g) { + firstLineWidth += graphemeAdvance(prepared, g); + } + const auto output = TL::layoutPreparedDocument(prepared, {}, firstLineWidth); + const auto& lines = output.lines; + + REQUIRE(lines.size() == 2); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 8 }); + REQUIRE(lines[1].graphemeRange == TL::GraphemeRange{ 8, 11 }); + REQUIRE(visualGraphemes(prepared, output, lines[0]) == + std::vector{ 0, 1, 2, 3, 6, 5, 4, 7 }); + REQUIRE(visualGraphemes(prepared, output, lines[1]) == std::vector{ 8, 9, 10 }); +} + +TEST_CASE("prepared paragraph wraps one RTL run containing two words", "[layout2][prepared][bidi]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + const std::array fonts{ &font }; + const std::u32string text = U"\u05E9\u05DC\u05D5\u05DD \u05E2\u05D5\u05DC\u05DD"; + const std::array boundaries{ 0, static_cast(text.size()) }; + const auto prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultRTL }, {}, "he"); + + REQUIRE(prepared.glyphRuns.size() == 1); + REQUIRE(prepared.glyphRuns[0].level % 2 == 1); + TL::LayoutUnit firstWordWidth = TL::kZero; + for (TL::GraphemeIndex g = 0; g < 5; ++g) { + firstWordWidth += graphemeAdvance(prepared, g); + } + const auto output = TL::layoutPreparedDocument(prepared, {}, firstWordWidth); + const auto& lines = output.lines; + + REQUIRE(lines.size() == 2); + REQUIRE(lines[0].graphemeRange == TL::GraphemeRange{ 0, 5 }); + REQUIRE(lines[1].graphemeRange == TL::GraphemeRange{ 5, 9 }); + REQUIRE(visualGraphemes(prepared, output, lines[0]) == std::vector{ 4, 3, 2, 1, 0 }); + REQUIRE(visualGraphemes(prepared, output, lines[1]) == std::vector{ 8, 7, 6, 5 }); +} + +TEST_CASE("shapeRun applies Latin ligatures with Lato", "[layout2][shape][ligatures]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + static constexpr std::array disableLiga{ TL::OpenTypeFeatureFlag{ + .feature = TL::fourCCToUint32("liga"), .enabled = false } }; + + const TL::FontDef enabledFont{ + .familyNames = families, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Regular, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = {}, + .variations = {}, + }; + const TL::FontDef disabledFont{ + .familyNames = families, + .fontSize = TL::k16, + .style = TL::FontStyle::Normal, + .weight = TL::FontWeight::Regular, + .lineHeight = TL::kZero, + .letterSpacing = TL::kZero, + .wordSpacing = TL::kZero, + .verticalAlign = TL::kZero, + .features = disableLiga, + .variations = {}, + }; + + const TL::FontHandle enabledHandle = database->resolveFont(enabledFont); + const TL::FontHandle disabledHandle = database->resolveFont(disabledFont); + REQUIRE(enabledHandle); + REQUIRE(disabledHandle); + + const std::u32string_view text = U"office affine efficient fi fl ffi ffl"; + std::vector enabledGlyphs; + std::vector disabledGlyphs; + const TL::DocumentSource document(text); + + TL::shapeRun(document, TL::CodepointRange{ 0, static_cast(text.size()) }, + TL::CodepointRange{ 0, static_cast(text.size()) }, raw(database), + { enabledHandle, 0 }, 0, scriptTag("Latn"), enabledFont, "en", + [&](const TL::Glyph& glyph, bool) { + enabledGlyphs.push_back(glyph); + }); + TL::shapeRun(document, TL::CodepointRange{ 0, static_cast(text.size()) }, + TL::CodepointRange{ 0, static_cast(text.size()) }, raw(database), + { disabledHandle, 0 }, 0, scriptTag("Latn"), disabledFont, "en", + [&](const TL::Glyph& glyph, bool) { + disabledGlyphs.push_back(glyph); + }); + + REQUIRE(enabledGlyphs.size() < disabledGlyphs.size()); + + const std::array fonts{ &enabledFont }; + const std::array boundaries{ 0, static_cast(text.size()) }; + const auto prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + const auto layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(560.0f)); +} + +TEST_CASE("caret and selection queries preserve logical boundaries inside a ligature", + "[layout2][queries][ligatures]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + font.fontSize = TL::k16; + const std::array fonts{ &font }; + const std::u32string_view text = U"ffi"; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, scriptTag("Latn"), "en"); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(100.0f)); + + REQUIRE(prepared.graphemeMap.graphemeCount() == 3); + REQUIRE(prepared.glyphs.size() < prepared.graphemeMap.graphemeCount()); + REQUIRE(layout.lines.size() == 1); + + std::array carets; + for (TL::GraphemeIndex boundary = 0; boundary <= 3; ++boundary) { + carets[boundary] = TL::caretPosition( + prepared, layout, + { boundary, boundary == 3 ? TL::CaretAffinity::Upstream : TL::CaretAffinity::Downstream }); + REQUIRE(carets[boundary].line == 0); + REQUIRE(carets[boundary].level == 0); + } + + REQUIRE(carets[0].x == layout.lines[0].originX); + REQUIRE(carets[0].x < carets[1].x); + REQUIRE(carets[1].x < carets[2].x); + REQUIRE(carets[2].x < carets[3].x); + REQUIRE(carets[3].x == layout.lines[0].originX + layout.lines[0].width); + + SECTION("hit testing round-trips every ligature caret") { + for (TL::GraphemeIndex boundary = 0; boundary <= 3; ++boundary) { + const TL::CaretIndex hit = + TL::hitTestPoint(prepared, layout, carets[boundary].x, layout.lines[0].baselineY); + REQUIRE(hit.grapheme == boundary); + REQUIRE(TL::caretPosition(prepared, layout, hit).x == carets[boundary].x); + } + } + + SECTION("selection rectangles use internal ligature caret boundaries") { + const std::vector middle = selectionRects(prepared, layout, { 1, 2 }); + REQUIRE(middle.size() == 1); + REQUIRE(middle[0].line == 0); + REQUIRE(middle[0].x0 == carets[1].x); + REQUIRE(middle[0].x1 == carets[2].x); + + const std::vector whole = selectionRects(prepared, layout, { 0, 3 }); + REQUIRE(whole.size() == 1); + REQUIRE(whole[0].x0 == carets[0].x); + REQUIRE(whole[0].x1 == carets[3].x); + } +} + +TEST_CASE("render complex multilingual layouts to grayscale PNG files", "[layout2][render][png]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Go Noto Kurrent-Regular" }; + TL::FontDef font = makeFont(); + font.familyNames = families; + font.fontSize = TL::fromFloat(28.0f); + const std::array fonts{ &font }; + + REQUIRE(database->resolveFont(font)); + + const auto writeAndCheck = [&](const TL::PreparedDocument& prepared, const TL::DocumentLayout& layout, + std::string_view filename) { + const std::filesystem::path outputPath = std::filesystem::current_path() / filename; + std::error_code error; + std::filesystem::remove(outputPath, error); + + (void)prepared; + (void)layout; + (void)outputPath; + // TODO: Render layout. + }; + + const auto render = [&](std::u32string_view text, TL::BaseDirection direction, std::string_view language, + std::string_view filename, TL::TextAlignment alignment = TL::TextAlignment::Start, + TL::TabStops tabStops = {}, bool allowBreakAnywhere = false, + size_t expectedLineCount = 0) { + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ direction }, {}, language); + const TL::DocumentLayout layout = TL::layoutPreparedDocument( + prepared, std::array{ alignment }, TL::fromFloat(100.0f), tabStops, {}, allowBreakAnywhere); + if (expectedLineCount != 0) { + REQUIRE(layout.lines.size() == expectedLineCount); + } + writeAndCheck(prepared, layout, filename); + }; + + SECTION("allow breaking anywhere at HarfBuzz-safe boundaries") { + // The narrow width forces breaks inside words; the generated PNG makes the visual result + // easy to inspect while HarfBuzz still prevents breaks inside unsafe shaping sequences. + render(U"extraordinary typography", TL::BaseDirection::LeftToRight, "en", + "text-layout-break-anywhere.png", TL::TextAlignment::Start, {}, true, 4); + } + + SECTION("Latin tab stops") { + // The repeated labels make the columns and all successive tab-stop advances easy to inspect. + render(U"Item\tQuantity\tUnit price\tTotal", TL::BaseDirection::LeftToRight, "en", + "text-layout-tabs-latin.png", TL::TextAlignment::Start, + { + .firstStop = TL::fromFloat(120.0f), + .interval = TL::fromFloat(120.0f), + }); + } + + SECTION("mixed BiDi tab stops") { + // Tabs are measured in logical order from the RTL paragraph margin before visual reordering. + render(U"\u05EA\u05D9\u05D0\u05D5\u05E8\tSKU A-104\t\u05DB\u05DE\u05D5\u05EA 12\tPrice $15.00", + TL::BaseDirection::RightToLeft, "he", "text-layout-tabs-mixed-bidi.png", + TL::TextAlignment::Start, + { + .firstStop = TL::fromFloat(120.0f), + .interval = TL::fromFloat(120.0f), + }); + } + + SECTION("Latin Greek Cyrillic and combining marks") { + render(U"Latin: caf\u00E9, na\u00EFve, A\u0308. \u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC: " + U"\u039A\u03B1\u03BB\u03B7\u03BC\u03AD\u03C1\u03B1 \u03BA\u03CC\u03C3\u03BC\u03B5. " + U"\u041A\u0438\u0440\u0438\u043B\u043B\u0438\u0446\u0430: " + U"\u041F\u0440\u0438\u0432\u0435\u0442, \u043C\u0438\u0440!", + TL::BaseDirection::DefaultLTR, "en", "text-layout-latin-greek-cyrillic.png"); + } + + SECTION("letter and word spacing with ligatures") { + static constexpr std::array latoFamilies{ "Lato" }; + static constexpr std::array enableLiga{ TL::OpenTypeFeatureFlag{ + TL::fourCCToUint32("liga"), true } }; + + TL::FontDef spacedFont = makeFont(); + spacedFont.familyNames = latoFamilies; + spacedFont.fontSize = TL::fromFloat(28.0f); + spacedFont.letterSpacing = TL::fromFloat(3.0f); + spacedFont.wordSpacing = TL::fromFloat(9.0f); + spacedFont.features = enableLiga; + const std::array spacedFonts{ &spacedFont }; + const std::u32string_view text = U"office affinity: ffi fi fl spaced words"; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument prepared = TL::prepareDocument( + TL::DocumentSource(text), database, spacedFonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(760.0f)); + REQUIRE(prepared.glyphs.size() < text.size()); // Shaping produced at least one ligature. + REQUIRE(layout.lines.size() >= 1); + writeAndCheck(prepared, layout, "text-layout-spacing-ligatures.png"); + + TL::FontDef letterOnlyFont = spacedFont; + letterOnlyFont.wordSpacing = TL::kZero; + const std::array letterOnlyFonts{ &letterOnlyFont }; + const TL::PreparedDocument letterOnly = TL::prepareDocument( + TL::DocumentSource(text), database, letterOnlyFonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + const TL::DocumentLayout letterOnlyLayout = + TL::layoutPreparedDocument(letterOnly, {}, TL::fromFloat(760.0f)); + REQUIRE(layout.horizontalTextBounds.distance() > letterOnlyLayout.horizontalTextBounds.distance()); + writeAndCheck(letterOnly, letterOnlyLayout, "text-layout-letter-spacing-ligatures.png"); + } + + SECTION("Arabic Hebrew and embedded Latin") { + render(U"English 123 \u2014 \u0627\u0644\u0639\u0631\u0628\u064A\u0629: " + U"\u0627\u0644\u0633\u064E\u0651\u0644\u064E\u0627\u0645\u064F " + U"\u0639\u064E\u0644\u064E\u064A\u0652\u0643\u064F\u0645\u0652 \u2014 " + U"\u05E2\u05D1\u05E8\u05D9\u05EA: \u05E9\u05B8\u05DC\u05D5\u05B9\u05DD " + U"\u05E2\u05D5\u05B9\u05DC\u05B8\u05DD \u2014 ABC 456", + TL::BaseDirection::RightToLeft, "ar", "text-layout-arabic-hebrew-bidi.png"); + } + + SECTION("Indic and Southeast Asian shaping") { + render( + U"\u0939\u093F\u0928\u094D\u0926\u0940: \u0928\u092E\u0938\u094D\u0924\u0947 " + U"\u0926\u0941\u0928\u093F\u092F\u093E\u0964 \u09AC\u09BE\u0982\u09B2\u09BE: " + U"\u09A8\u09AE\u09B8\u09CD\u0995\u09BE\u09B0 \u09AC\u09BF\u09B6\u09CD\u09AC\u0964 " + U"\u0E44\u0E17\u0E22: \u0E2A\u0E27\u0E31\u0E2A\u0E14\u0E35\u0E0A\u0E32\u0E27\u0E42\u0E25\u0E01 " + U"\u0DC3\u0D82\u0DBD\u0D82\u0DA4: \u0D86\u0DAF\u0DBA\u0D94\u0DBA\u0DB1\u0DCA", + TL::BaseDirection::DefaultLTR, "hi", "text-layout-indic-southeast-asian.png"); + } + + SECTION("CJK and emoji sequences") { + render( + U"\u65E5\u672C\u8A9E\u306E\u7D44\u7248\u3001\u4E2D\u56FD\u8A9E\u6392\u7248\u3002 " + U"Emoji: \U0001F468\u200D\U0001F4BB \U0001F469\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466 " + U"\U0001F44D\U0001F3FD \U0001F1FA\U0001F1F8 \u2615\uFE0F", + TL::BaseDirection::DefaultLTR, "ja", "text-layout-cjk-emoji.png"); + } + + SECTION("Hangul shaping") { + // Go Noto Kurrent stores modern Hangul as conjoining Jamo and relies on + // Hangul-specific OpenType features. A Japanese language tag selects the + // font's JAN language system, which intentionally omits those features. + render(U"\uD55C\uAE00 \uD14D\uC2A4\uD2B8. \uB300\uD55C\uBBFC\uAD6D \uD55C\uAD6D\uC5B4 \uC870\uD310.", + TL::BaseDirection::DefaultLTR, "ko", "text-layout-hangul.png"); + } + + SECTION("paired brackets across directional runs") { + render(U"LTR outer: [English (\u05E2\u05D1\u05E8\u05D9\u05EA) " + U"\u0627\u0644\u0639\u0631\u0628\u064A\u0629] {123 (ABC)} \u2014 " + U"RTL outer: [\u05E9\u05DC\u05D5\u05DD (English) \u0645\u0631\u062D\u0628\u0627]", + TL::BaseDirection::DefaultLTR, "en", "text-layout-paired-brackets.png"); + } + + SECTION("DefaultRTL with neutral and numeric prefixes") { + render(U"123 (ABC) \u2014 \u05E9\u05B8\u05DC\u05D5\u05B9\u05DD [English 456] \u2014 " + U"\u0627\u0644\u0639\u0631\u0628\u064A\u0629 (test) \u2014 !!!", + TL::BaseDirection::DefaultRTL, "he", "text-layout-default-rtl.png"); + } + + SECTION("center-aligned wrapped text") { + const std::u32string_view text = + U"Centered multilingual text \u2014 \u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC, " + U"\u0627\u0644\u0639\u0631\u0628\u064A\u0629, \u05E2\u05D1\u05E8\u05D9\u05EA, \u65E5\u672C\u8A9E " + U"\u2014 " + U"with lines of deliberately different widths."; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + const TL::DocumentLayout layout = TL::layoutPreparedDocument( + prepared, std::array{ TL::TextAlignment::Center }, TL::fromFloat(420.0f)); + REQUIRE(layout.lines.size() >= 2); + REQUIRE(std::ranges::any_of(layout.lines, [](const TL::LayoutLine& line) { + return line.originX > TL::kZero; + })); + writeAndCheck(prepared, layout, "text-layout-center-aligned.png"); + } + + SECTION("multiple font families and sizes") { + static constexpr std::array notoFamily{ "Go Noto Kurrent-Regular" }; + static constexpr std::array latoFamily{ "Lato" }; + static constexpr std::array codeFamily{ "Source Code Pro" }; + + TL::FontDef smallNoto = makeFont(); + smallNoto.familyNames = notoFamily; + smallNoto.fontSize = TL::fromFloat(18.0f); + TL::FontDef largeLato = makeFont(); + largeLato.familyNames = latoFamily; + largeLato.fontSize = TL::fromFloat(38.0f); + largeLato.weight = TL::FontWeight::Medium; + TL::FontDef mediumCode = makeFont(); + mediumCode.familyNames = codeFamily; + mediumCode.fontSize = TL::fromFloat(25.0f); + mediumCode.weight = TL::FontWeight::Medium; + TL::FontDef largeNoto = smallNoto; + largeNoto.fontSize = TL::fromFloat(32.0f); + + const std::array styledFonts{ &smallNoto, &largeLato, &mediumCode, &largeNoto }; + const std::u32string part1 = U"Small multilingual: \u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC " + U"\u0627\u0644\u0639\u0631\u0628\u064A\u0629 "; + const std::u32string part2 = U"LARGE LATO "; + const std::u32string part3 = U"Code: != -> ffi "; + const std::u32string part4 = + U"\u65E5\u672C\u8A9E \u0939\u093F\u0928\u094D\u0926\u0940 \u05E2\u05D1\u05E8\u05D9\u05EA"; + const std::u32string text = part1 + part2 + part3 + part4; + const std::array boundaries{ + 0, + static_cast(part1.size()), + static_cast(part1.size() + part2.size()), + static_cast(part1.size() + part2.size() + part3.size()), + static_cast(text.size()), + }; + const TL::PreparedDocument prepared = TL::prepareDocument( + TL::DocumentSource(text), database, styledFonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(760.0f)); + + REQUIRE(prepared.glyphRuns.size() >= styledFonts.size()); + REQUIRE(std::ranges::any_of(prepared.glyphRuns, [&](const TL::GlyphRun& run) { + return run.fontRunIndex == 1 && run.fontHandle; + })); + writeAndCheck(prepared, layout, "text-layout-various-fonts-sizes.png"); + } + + SECTION("positive and hanging textIndent visual rendering") { + const std::u32string_view text = + U"Typography indent test: the first line of this paragraph is visibly indented, " + U"while subsequent wrapped lines align to the left margin correctly."; + const std::array boundaries{ 0, static_cast(text.size()) }; + const TL::PreparedDocument prepared = + TL::prepareDocument(TL::DocumentSource(text), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR }, {}, "en"); + + // Positive text-indent (first-line indent) + const TL::DocumentLayout positiveIndentLayout = TL::layoutPreparedDocument( + prepared, {}, TL::fromFloat(400.0f), {}, std::array{ TL::fromFloat(40.0f) }); + REQUIRE(positiveIndentLayout.lines.size() >= 2); + CHECK(positiveIndentLayout.lines[0].originX == TL::fromFloat(40.0f)); + CHECK(positiveIndentLayout.lines[1].originX == TL::fromFloat(0.0f)); + writeAndCheck(prepared, positiveIndentLayout, "text-layout-indent-positive.png"); + + // Negative text-indent (hanging indent) + const TL::DocumentLayout hangingIndentLayout = TL::layoutPreparedDocument( + prepared, {}, TL::fromFloat(400.0f), {}, std::array{ TL::fromFloat(-30.0f) }); + REQUIRE(hangingIndentLayout.lines.size() >= 2); + CHECK(hangingIndentLayout.lines[0].originX == TL::fromFloat(-30.0f)); + CHECK(hangingIndentLayout.lines[1].originX == TL::fromFloat(0.0f)); + writeAndCheck(prepared, hangingIndentLayout, "text-layout-indent-hanging.png"); + } +} + +TEST_CASE("render English alphabet with hinting enabled and disabled", "[layout2][render][png][hinting]") { + const auto database = TL::getDefaultFontDatabase(); + REQUIRE(database != nullptr); + + static constexpr std::array families{ "Lato" }; + + TL::FontDef enabledFont = makeFont(); + enabledFont.familyNames = families; + enabledFont.fontSize = TL::fromFloat(18.0f); + enabledFont.hinting = TL::Hinting::Enable; + TL::FontDef disabledFont = enabledFont; + disabledFont.hinting = TL::Hinting::Disable; + + const std::u32string_view text = U"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const std::array fonts{ &enabledFont, &disabledFont }; + const std::u32string doubled = std::u32string(text) + U"\n" + std::u32string(text); + const std::array boundaries{ + 0, + static_cast(text.size()) + 1, + static_cast(doubled.size()), + }; + + const TL::PreparedDocument prepared = TL::prepareDocument( + TL::DocumentSource(doubled), database, fonts, TL::BoundaryTable{ boundaries }, + std::array{ TL::BaseDirection::DefaultLTR, TL::BaseDirection::DefaultLTR }, {}, "en"); + const TL::DocumentLayout layout = TL::layoutPreparedDocument(prepared, {}, TL::fromFloat(600.0f)); + + REQUIRE(layout.lines.size() == 2); + // The two lines use different font runs (one per hinting policy). + REQUIRE(prepared.glyphRuns.size() >= 2); + REQUIRE(std::ranges::any_of(prepared.glyphRuns, [](const TL::GlyphRun& run) { + return run.fontRunIndex == 0 && run.fontHandle; + })); + REQUIRE(std::ranges::any_of(prepared.glyphRuns, [](const TL::GlyphRun& run) { + return run.fontRunIndex == 1 && run.fontHandle; + })); + + // TODO: Render layout. +} + +TEST_CASE("GreedyLineBreaker textIndent indents first line and adjusts line wrap", "[layout2][textIndent]") { + // 4 words of 20px advance each: "aa bb cc dd" + // Grapheme advances: 20, 20, 20, 20 + std::vector opportunities(4, kNoBreak); + opportunities[0] = TL::LineBreakKind::AllowBreak; + opportunities[1] = TL::LineBreakKind::AllowBreak; + opportunities[2] = TL::LineBreakKind::AllowBreak; + + const auto prepared = preparedDocument(U"abcd", std::move(opportunities), + { advance(TL::fromFloat(20.0f)), advance(TL::fromFloat(20.0f)), + advance(TL::fromFloat(20.0f)), advance(TL::fromFloat(20.0f)) }); + + SECTION("positive textIndent in LTR shifts first line origin and wraps earlier") { + // maxLineWidth = 50. First line available width = 50 - 15 = 35. + // First line fits 1 word (20px <= 35px), wraps at 2nd word (40px > 35px). + // Remaining lines have maxLineWidth = 50, fitting 2 words (40px <= 50px). + // Total 3 lines: [0..1] (20px), [1..3] (40px), [3..4] (20px). + const TL::LayoutUnit maxLineWidth = TL::fromFloat(50.0f); + const TL::LayoutUnit textIndent = TL::fromFloat(15.0f); + + const auto layout = + TL::layoutPreparedDocument(prepared, {}, maxLineWidth, {}, std::array{ textIndent }); + + REQUIRE(layout.lines.size() == 3); + CHECK(layout.lines[0].originX == TL::fromFloat(15.0f)); + CHECK(layout.lines[0].graphemeRange == TL::GraphemeRange{ 0, 1 }); + CHECK(layout.lines[1].originX == TL::fromFloat(0.0f)); + CHECK(layout.lines[1].graphemeRange == TL::GraphemeRange{ 1, 3 }); + CHECK(layout.lines[2].originX == TL::fromFloat(0.0f)); + CHECK(layout.lines[2].graphemeRange == TL::GraphemeRange{ 3, 4 }); + } + + SECTION( + "negative textIndent (hanging indent) outdents first line and allows more content on first line") { + // maxLineWidth = 30. First line available width = 30 - (-15) = 45. + // First line fits 2 words (40px <= 45px). + // Second line maxLineWidth = 30, fits 1 word (20px <= 30px). + // Third line fits 1 word. + const TL::LayoutUnit maxLineWidth = TL::fromFloat(30.0f); + const TL::LayoutUnit textIndent = TL::fromFloat(-15.0f); + + const auto layout = + TL::layoutPreparedDocument(prepared, {}, maxLineWidth, {}, std::array{ textIndent }); + + REQUIRE(layout.lines.size() == 3); + CHECK(layout.lines[0].originX == TL::fromFloat(-15.0f)); + CHECK(layout.lines[0].graphemeRange == TL::GraphemeRange{ 0, 2 }); + CHECK(layout.lines[1].originX == TL::fromFloat(0.0f)); + CHECK(layout.lines[1].graphemeRange == TL::GraphemeRange{ 2, 3 }); + CHECK(layout.lines[2].originX == TL::fromFloat(0.0f)); + CHECK(layout.lines[2].graphemeRange == TL::GraphemeRange{ 3, 4 }); + } + + SECTION("textIndent with Right alignment") { + const TL::LayoutUnit maxLineWidth = TL::fromFloat(100.0f); + const TL::LayoutUnit textIndent = TL::fromFloat(10.0f); + + const auto layout = TL::layoutPreparedDocument(prepared, std::array{ TL::TextAlignment::Right }, + maxLineWidth, {}, std::array{ textIndent }); + + REQUIRE(layout.lines.size() == 1); + // remainingSpace = 100 - 80 = 20. originX = remainingSpace - textIndent = 20 - 10 = 10. + CHECK(layout.lines[0].originX == TL::fromFloat(10.0f)); + } +} diff --git a/src/graphics/TextLayout_test.cpp b/src/graphics/TextLayout_test.cpp new file mode 100644 index 00000000..7a4df2ad --- /dev/null +++ b/src/graphics/TextLayout_test.cpp @@ -0,0 +1,610 @@ +/* + * Brisk + * + * Cross-platform application framework + * -------------------------------------------------------------- + * + * Copyright (C) 2025 Brisk Developers + * + * This file is part of the Brisk library. + * + * Brisk is dual-licensed under the GNU General Public License, version 2 (GPL-2.0+), + * and a commercial license. You may use, modify, and distribute this software under + * the terms of the GPL-2.0+ license if you comply with its conditions. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include "VisualTests.hpp" +#include +#include +#include "FontInternals.hpp" + +namespace Brisk { + +static void registerTextLayoutTestFont() { + static bool registered = false; + if (registered) { + return; + } + + REQUIRE(fonts.has_value()); + REQUIRE(fonts + ->addFontFromFile(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf", + "TextLayoutTest") + .has_value()); + + registered = true; +} + +static void registerTextLayoutVisualFonts() { + static bool registered = false; + if (registered) { + return; + } + REQUIRE(fonts.has_value()); + + REQUIRE(fonts + ->addFontFromFile(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / + "GoNotoKurrent-Regular.ttf", + "TextLayoutNoto") + .has_value()); + REQUIRE(fonts + ->addFontFromFile(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / + "SourceCodePro-Medium.ttf", + "TextLayoutMono") + .has_value()); + REQUIRE(fonts + ->addFontFromFile(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "Lato-Medium.ttf", + "TextLayoutTest") + .has_value()); + registered = true; +} + +TEST_CASE("TextEngine prepared document exposes document data", "[text-layout]") { + registerTextLayoutTestFont(); + + const Font font{ "TextLayoutTest", 24.f }; + const TextWithOptions text{ U"Hello world" }; + const ShapedText shapedText = fonts->shapeText(font, text); + + CHECK_FALSE(shapedText.empty()); + CHECK(shapedText.characterCount() == 11); + CHECK(shapedText.graphemeCount() == 11); + REQUIRE(shapedText.graphemeBoundaries().size() == 12); + CHECK(shapedText.graphemeBoundaries().front() == 0); + CHECK(shapedText.graphemeBoundaries().back() == shapedText.characterCount()); + + for (uint32_t character = 0; character <= shapedText.characterCount(); ++character) { + const uint32_t grapheme = shapedText.characterToGrapheme(character); + REQUIRE(grapheme <= shapedText.graphemeCount()); + CHECK(shapedText.graphemeToCharacter(grapheme) <= character); + } + + REQUIRE(shapedText.paragraphCount() == 1); + const ShapedParagraph paragraph = shapedText.paragraph(0); + CHECK(paragraph.characterRange == Range{ 0, 11 }); + CHECK(paragraph.graphemeRange == Range{ 0, 11 }); + CHECK(paragraph.direction == TextDirection::LTR); +} + +TEST_CASE("TextEngine wraps and exposes line data", "[text-layout]") { + registerTextLayoutTestFont(); + + const Font font{ "TextLayoutTest", 24.f }; + const TextWithOptions text{ U"One two three four five" }; + const ShapedText shapedText = fonts->shapeText(font, text); + + TextLayoutOptions options; + options.maxLineWidth = 50.f; + options.alignment = TextLayoutAlignment::Left; + const TextLayout layout = shapedText.layout(options); + + CHECK_FALSE(layout.empty()); + CHECK(layout.lineCount() > 1); + CHECK(layout.bounds().width() > 0.f); + CHECK(layout.trimmedBounds().width() > 0.f); + + for (size_t index = 0; index < layout.lineCount(); ++index) { + const TextLine line = layout.line(index); + CHECK(line.characterRange.min <= line.characterRange.max); + CHECK(line.graphemeRange.min <= line.graphemeRange.max); + CHECK(line.width >= 0.f); + CHECK(line.trimmedWidth >= 0.f); + CHECK(line.trimmedWidth <= line.width); + CHECK(line.ascender >= 0.f); + CHECK(line.descender <= 0.f); + } + + const CaretPosition first = layout.caretPosition({ 0, CaretAffinity::Downstream }); + const CaretPosition last = layout.caretPosition({ shapedText.graphemeCount(), CaretAffinity::Upstream }); + CHECK(first.line < layout.lineCount()); + CHECK(last.line < layout.lineCount()); + CHECK((last.x >= first.x || last.line > first.line)); + + const CaretIndex hit = layout.hitTest({ first.x, first.x }); + CHECK(hit.grapheme <= shapedText.graphemeCount()); + + size_t selectionCount = 0; + layout.selectionRects({ 0, shapedText.graphemeCount() }, [&](const TextSelectionRect& rect) { + ++selectionCount; + CHECK(rect.line < layout.lineCount()); + CHECK(rect.x0 <= rect.x1); + }); + CHECK(selectionCount > 0); +} + +TEST_CASE("TextEngine identifies paragraph separator graphemes", "[text-layout]") { + registerTextLayoutTestFont(); + + const ShapedText prepared = + fonts->shapeText(Font{ "TextLayoutTest", 20.f }, TextWithOptions{ U"abc\ndef" }); + + CHECK(prepared.graphemeCount() == 7); + for (uint32_t grapheme = 0; grapheme < prepared.graphemeCount(); ++grapheme) { + CHECK(prepared.isParagraphSeparator(grapheme) == (grapheme == 3)); + } + CHECK_FALSE(prepared.isParagraphSeparator(prepared.graphemeCount())); +} + +TEST_CASE("TextEngine gives a trailing empty line visible caret metrics", "[text-layout]") { + registerTextLayoutTestFont(); + + const ShapedText prepared = fonts->shapeText(Font{ "TextLayoutTest", 20.f }, TextWithOptions{ U"abc\n" }); + const TextLayout layout = prepared.layout(); + + REQUIRE(layout.lineCount() == 2); + const TextLine emptyLine = layout.line(1); + CHECK(emptyLine.characterRange.empty()); + CHECK(emptyLine.ascender > 0.f); + CHECK(emptyLine.descender < 0.f); + CHECK_FALSE(layout.caretRect(layout.lineBeginning(1)).empty()); +} + +TEST_CASE("TextEngine indexed access handles invalid indices", "[text-layout]") { + registerTextLayoutTestFont(); + + const ShapedText prepared = fonts->shapeText(Font{ "TextLayoutTest", 20.f }, TextWithOptions{ U"x" }); + const TextLayout layout = prepared.layout(); + + CHECK(prepared.paragraph(100).characterRange.empty()); + CHECK(layout.line(100).characterRange.empty()); + CHECK(prepared.graphemeToCharacter(100) == prepared.characterCount()); +} + +TEST_CASE("TextEngine interaction APIs expose character and line queries", "[text-layout][interaction]") { + registerTextLayoutTestFont(); + + const ShapedText prepared = + fonts->shapeText(Font{ "TextLayoutTest", 20.f }, TextWithOptions{ U"first second third" }); + TextLayoutOptions options; + options.maxLineWidth = 55.f; + const TextLayout layout = prepared.layout(options); + + const CaretIndex characterCaret = prepared.caretFromCharacter(6, CaretAffinity::Downstream); + CHECK(prepared.characterFromCaret(characterCaret) == 6); + CHECK(layout.lineForCharacter(6) == layout.lineForCaret(characterCaret)); + + for (size_t index = 0; index < layout.lineCount(); ++index) { + const CaretIndex beginning = layout.lineBeginning(index); + const CaretIndex ending = layout.lineEnd(index); + CHECK(beginning.affinity == CaretAffinity::Downstream); + CHECK(ending.affinity == CaretAffinity::Upstream); + CHECK(layout.lineForCaret(beginning) == index); + CHECK(layout.lineForCaret(ending) == index); + + const RectangleF caret = layout.caretRect(beginning); + const TextLine line = layout.line(index); + const float halfLeading = line.leading * 0.5f; + CHECK(caret.y1 == line.baseline - line.ascender - halfLeading); + CHECK(caret.y2 == line.baseline - line.descender + halfLeading); + } + + const CaretPosition current = layout.caretPosition(layout.lineBeginning(0)); + const CaretIndex next = layout.moveCaretVertically(layout.lineBeginning(0), current.x, 1); + CHECK(layout.lineForCaret(next) == (layout.lineCount() > 1 ? 1 : 0)); + + size_t characterRectangles = 0; + layout.selectionRectsByCharacter({ 2, 14 }, [&](const TextSelectionRect& rect) { + ++characterRectangles; + CHECK(rect.line < layout.lineCount()); + CHECK(rect.x0 <= rect.x1); + }); + CHECK(characterRectangles > 0); +} + +TEST_CASE("TextEngine preparation validates font input", "[text-layout]") { + registerTextLayoutTestFont(); + + REQUIRE_THROWS_AS(fonts->shapeText(TextWithOptions{ U"text" }, {}, {}), EArgument); + + const FontAndColor styles[2]{ { Font{ "TextLayoutTest", 20.f } }, { Font{ "TextLayoutTest", 20.f } } }; + const uint32_t invalidOffsets[1]{ 0 }; + REQUIRE_THROWS_AS(fonts->shapeText(TextWithOptions{ U"text" }, styles, invalidOffsets), EArgument); + + const uint32_t outOfRangeOffsets[1]{ 4 }; + REQUIRE_THROWS_AS(fonts->shapeText(TextWithOptions{ U"text" }, styles, outOfRangeOffsets), EArgument); + + const uint32_t duplicateOffsets[2]{ 1, 1 }; + const FontAndColor threeStyles[3]{ { Font{ "TextLayoutTest", 20.f } }, + { Font{ "TextLayoutTest", 20.f } }, + { Font{ "TextLayoutTest", 20.f } } }; + REQUIRE_THROWS_AS(fonts->shapeText(TextWithOptions{ U"text" }, threeStyles, duplicateOffsets), EArgument); +} + +TEST_CASE("TextEngine honors text layout options", "[text-layout]") { + registerTextLayoutTestFont(); + + const Font font{ "TextLayoutTest", 20.f }; + const ShapedText wrapped = fonts->shapeText(font, TextWithOptions{ U"one two three" }); + TextLayoutOptions options; + options.maxLineWidth = 30.f; + CHECK(wrapped.layout(options).lineCount() > 1); + + const ShapedText singleLine = + fonts->shapeText(font, TextWithOptions{ U"one two three", TextOptions::SingleLine }); + CHECK(singleLine.layout(options).lineCount() == 1); + + const ShapedText wrapAnywhere = + fonts->shapeText(font, TextWithOptions{ U"onetwothree", TextOptions::WrapAnywhere }); + CHECK(wrapAnywhere.layout(options).lineCount() > 1); +} + +TEST_CASE("TextEngine CPU rasterization supports oversampling", "[text-layout]") { + registerTextLayoutTestFont(); + + const ShapedText prepared = fonts->shapeText(Font{ "TextLayoutTest", 24.f }, TextWithOptions{ U"A" }); + const TextLayout layout = prepared.layout(); + CHECK(layout.lineCount() == 1); + + std::optional firstGlyph; + Internal::loadTextLayoutGlyphRun(prepared, 0, nullptr, + [&](uint32_t, const Internal::TextLayoutGlyphBitmap& glyph) { + if (!firstGlyph) { + firstGlyph = glyph; + } + }); + REQUIRE(firstGlyph.has_value()); + CHECK_FALSE(firstGlyph->color); + CHECK(firstGlyph->horizontalScale == fonts->hscale()); + REQUIRE(firstGlyph->sprite); + CHECK(firstGlyph->sprite->size.width > 0); + CHECK(firstGlyph->sprite->size.height > 0); +} + +TEST_CASE("TextEngine CPU rasterization supports SVG glyphs", "[text-layout]") { + REQUIRE(fonts.has_value()); + const auto registered = fonts->addFontFromFile( + fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "NotoColorEmoji-SVG.otf", "TextLayoutEmoji"); + REQUIRE(registered); + + const ShapedText prepared = fonts->shapeText(Font{ "TextLayoutEmoji", 48.f }, TextWithOptions{ U"😀" }); + const TextLayout layout = prepared.layout(); + CHECK(layout.lineCount() == 1); + + std::optional glyph; + Internal::loadTextLayoutGlyphRun(prepared, 0, nullptr, + [&](uint32_t, const Internal::TextLayoutGlyphBitmap& loadedGlyph) { + if (!glyph) { + glyph = loadedGlyph; + } + }); + REQUIRE(glyph.has_value()); + CHECK(glyph->color); + CHECK(glyph->horizontalScale == 1); + REQUIRE(glyph->sprite); + CHECK(glyph->sprite->size.width > 0); + CHECK(glyph->sprite->size.height > 0); + bool hasPixels = false; + for (const auto value : glyph->sprite->bytes()) { + hasPixels |= value != std::byte{}; + } + CHECK(hasPixels); +} + +TEST_CASE("TextEngine CPU renderer handles positioned glyphs", "[text-layout]") { + registerTextLayoutTestFont(); + + const ShapedText prepared = fonts->shapeText(Font{ "TextLayoutTest", 24.f }, TextWithOptions{ U"A" }); + const TextLayout layout = prepared.layout(); + Rc image = rcnew Image(Size{ 128, 64 }, ImageFormat::Greyscale_U8Gamma); + { + auto pixels = image->mapWrite(); + pixels.clear(Color(30, 34, 38)); + } + + Internal::renderPreparedDocument(image, Point{ 8, 32 }, prepared, layout); + + const auto pixels = image->mapRead(); + size_t nonzero = 0; + for (int y = 0; y < pixels.height(); ++y) { + for (int x = 0; x < pixels.width(); ++x) { + nonzero += pixels(x, y).grey != 0; + } + } + CHECK(nonzero > 0); +} + +TEST_CASE("TextEngine CPU renderer saves emoji", "[text-layout]") { + REQUIRE(fonts.has_value()); + const auto registered = fonts->addFontFromFile(fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / + "NotoColorEmoji-SVG.otf", + "TextLayoutEmojiPng"); + REQUIRE(registered); + + const ShapedText prepared = + fonts->shapeText(Font{ "TextLayoutEmojiPng", 48.f }, TextWithOptions{ U"😀 👑 🌟" }); + const TextLayout layout = prepared.layout(); + Rc image = rcnew Image(Size{ 320, 100 }, ImageFormat::RGBA_U8Gamma, ColorW(Palette::white)); + Internal::renderPreparedDocument(image, Point{ 8, 32 }, prepared, layout); +} + +TEST_CASE("TextEngine Canvas renderer", "[text-layout][visual]") { + registerTextLayoutTestFont(); + + const Font font{ "TextLayoutTest", 24.f }; + const ShapedText prepared = + fonts->shapeText(font, TextWithOptions{ U"New document layout\nwraps and renders through Canvas" }); + + TextLayoutOptions options; + options.maxLineWidth = 270.f; + const TextLayout layout = prepared.layout(options); + + renderTest( + "text-layout-canvas", Size{ 360, 180 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(245, 247, 250)); + canvas.fillRect({ 0, 0, 360, 180 }); + canvas.setFillColor(Palette::black); + canvas.fillText({ 32, 24 }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas renderer line-height bounding rectangles", "[text-layout][visual]") { + registerTextLayoutTestFont(); + + constexpr float lineHeights[] = { 0.f, 1.f, 1.25f, 1.5f }; + std::vector layouts; + layouts.reserve(std::size(lineHeights)); + + for (const float lineHeight : lineHeights) { + Font font{ "TextLayoutTest", 24.f }; + font.lineHeight = lineHeight; + + const ShapedText prepared = fonts->shapeText( + font, TextWithOptions{ U"Line one with a descender\nLine two with a descender" }); + TextLayoutOptions options; + options.maxLineWidth = 280.f; + layouts.push_back(prepared.layout(options)); + } + + renderTest( + "text-layout-line-height-bounds", Size{ 360, 340 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(248, 249, 252)); + canvas.fillRect({ 0, 0, 360, 340 }); + + for (size_t index = 0; index < layouts.size(); ++index) { + const PointF origin{ 32.f, 24.f + static_cast(index) * 78.f }; + const RectangleF bounds = layouts[index].bounds(); + const RectangleF box{ origin.x + bounds.x1, origin.y + bounds.y1, origin.x + bounds.x2, + origin.y + bounds.y2 }; + + canvas.setFillColor(Color(190, 215, 245, 110)); + canvas.fillRect(box); + canvas.setStrokeColor(Color(70, 125, 190)); + canvas.setStrokeWidth(1.f); + canvas.strokeRect(box); + + canvas.setFillColor(Palette::black); + canvas.fillText(origin, layouts[index]); + } + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas renderer styles and selection", "[text-layout][visual]") { + registerTextLayoutTestFont(); + + Font decorated = Font{ "TextLayoutTest", 24.f }; + decorated.textDecoration = TextDecoration::Underline | TextDecoration::Overline; + const FontAndColor styles[]{ + { Font{ "TextLayoutTest", 24.f }, Palette::Standard::blue }, + { decorated, Palette::Standard::red }, + }; + const TextWithOptions source{ U"Blue text, decorated red text" }; + const uint32_t styleOffset[]{ 15 }; + const ShapedText prepared = fonts->shapeText(source, styles, styleOffset); + TextLayoutOptions options; + options.maxLineWidth = 320.f; + const TextLayout layout = prepared.layout(options); + + renderTest( + "text-layout-canvas-styles", Size{ 400, 140 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(255, 255, 255)); + canvas.fillRect({ 0, 0, 400, 140 }); + canvas.setFillColor(Color(225, 230, 240)); + canvas.fillTextSelection({ 32, 24 }, layout, { 5, 24 }); + canvas.fillText({ 32, 24 }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas renderer bidirectional text", "[text-layout][visual][bidi]") { + registerTextLayoutVisualFonts(); + + const Font font{ "TextLayoutNoto", 26.f }; + const ShapedText prepared = fonts->shapeText( + font, TextWithOptions{ U"English שלום עולם — العربية مرحبًا بالعالم\nLTR 123 אבג 456 RTL" }); + TextLayoutOptions options; + options.maxLineWidth = 520.f; + const TextLayout layout = prepared.layout(options); + + renderTest( + "text-layout-bidi", Size{ 600, 150 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(248, 249, 252)); + canvas.fillRect({ 0, 0, 600, 150 }); + canvas.setFillColor(Palette::black); + canvas.fillText({ 28, 26 }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas renderer wraps bidirectional text", "[text-layout][visual][bidi]") { + registerTextLayoutVisualFonts(); + + const Font font{ "TextLayoutNoto", 24.f }; + const ShapedText prepared = fonts->shapeText( + font, TextWithOptions{ U"هذه جملة عربية طويلة للاختبار مع English words بين النصوص العربية" }); + TextLayoutOptions options; + options.maxLineWidth = 270.f; + const TextLayout layout = prepared.layout(options); + + renderTest( + "text-layout-bidi-wrap", Size{ 340, 260 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(255, 255, 255)); + canvas.fillRect({ 0, 0, 340, 260 }); + canvas.setFillColor(Palette::black); + canvas.fillText({ 24, 24 }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas renderer supports monospace", "[text-layout][visual][fonts]") { + registerTextLayoutVisualFonts(); + const FontAndColor styles[]{ + { Font{ "TextLayoutMono", 23.f }, Palette::black }, + }; + const TextWithOptions source{ + U" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + }; + const ShapedText prepared = fonts->shapeText(source, std::span{ styles }, {}); + const TextLayoutOptions options{ .maxLineWidth = 600.f - 48.f, .allowBreakAnywhere = true }; + const TextLayout layout = prepared.layout(options); + REQUIRE(layout.lineCount() == 3); + REQUIRE(layout.bounds().area() > 500.f); + + renderTest( + "text-layout-monospace", Size{ 600, 120 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(250, 250, 250)); + canvas.fillRect({ 0, 0, 600, 120 }); + canvas.fillText({ 24, 4 }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas renderer supports multiple fonts", "[text-layout][visual][fonts]") { + registerTextLayoutVisualFonts(); + const auto registered = fonts->addFontFromFile( + fs::path(PROJECT_SOURCE_DIR) / "resources" / "fonts" / "NotoColorEmoji-SVG.otf", "TextLayoutEmoji"); + REQUIRE(registered); + + const FontAndColor styles[]{ + { Font{ "TextLayoutTest", 25.f }, Palette::Standard::blue }, + { Font{ "TextLayoutMono", 23.f }, Palette::Standard::red }, + { Font{ "TextLayoutNoto", 25.f }, Palette::Standard::green }, + { Font{ "TextLayoutEmoji", 25.f }, Palette::Standard::green }, + }; + const TextWithOptions source{ U"Lato text | monospaced text | שלום עולם | 😀 👑 🌟" }; + const uint32_t offsets[]{ 11, 30, 42 }; + const ShapedText prepared = fonts->shapeText(source, std::span{ styles }, offsets); + const TextLayout layout = prepared.layout(); + + renderTest( + "text-layout-multiple-fonts", Size{ 720, 120 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(250, 250, 250)); + canvas.fillRect({ 0, 0, 720, 120 }); + canvas.fillText({ 24, 28 }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas renderer draws multiline selection", "[text-layout][visual][selection]") { + registerTextLayoutTestFont(); + + const Font font{ "TextLayoutTest", 24.f }; + const TextWithOptions source{ U"First line of selectable text\nSecond line is selected\nThird line" }; + const ShapedText prepared = fonts->shapeText(font, source); + TextLayoutOptions options; + options.maxLineWidth = 300.f; + const TextLayout layout = prepared.layout(options); + + renderTest( + "text-layout-selection-multiline", Size{ 380, 180 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(255, 255, 255)); + canvas.fillRect({ 0, 0, 380, 180 }); + canvas.setFillColor(Color(180, 210, 255)); + canvas.fillTextSelection({ 24, 24 }, layout, { 6, 56 }); + canvas.setFillColor(Palette::black); + canvas.fillText({ 24, 24 }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas renderer draws ligatures", "[text-layout][visual][ligatures]") { + registerTextLayoutVisualFonts(); + + Font font{ "TextLayoutTest", 30.f }; + font.features = { OpenTypeFeatureFlag{ OpenTypeFeature::liga, true } }; + const ShapedText prepared = + fonts->shapeText(font, TextWithOptions{ U"fi fl ffi ffl office affine official" }); + const TextLayout layout = prepared.layout(); + + renderTest( + "text-layout-ligatures", Size{ 620, 100 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(245, 247, 250)); + canvas.fillRect({ 0, 0, 620, 100 }); + canvas.setFillColor(Palette::black); + canvas.fillText({ 20, 28 }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +TEST_CASE("TextEngine Canvas centered text", "[text-layout][visual]") { + registerTextLayoutVisualFonts(); + + Font font{ "TextLayoutTest", 18.f }; + const ShapedText prepared = + fonts->shapeText(font, TextWithOptions{ U"Centered text layout with no wrapping" }); + TextLayoutOptions options; + options.maxLineWidth = HUGE_VALF; // No wrapping, single line + options.alignment = TextLayoutAlignment::Center; + const TextLayout layout = prepared.layout(options); + + renderTest( + "text-layout-centered", Size{ 620, 100 }, + [&](RenderContext& context) { + Canvas canvas(context); + canvas.setFillColor(Color(245, 247, 250)); + canvas.fillRect({ 0, 0, 620, 100 }); + canvas.setFillColor(Palette::black); + canvas.fillText({ 310, 50 }, { 0.5f, 0.5f }, layout); + }, + ColorF{ 1.f, 1.f }); +} + +} // namespace Brisk diff --git a/src/graphics/vector/Raster.cpp b/src/graphics/vector/Raster.cpp index 773a9661..f10c1faa 100644 --- a/src/graphics/vector/Raster.cpp +++ b/src/graphics/vector/Raster.cpp @@ -90,8 +90,10 @@ void FTOutline::grow(size_t points, size_t segments) { reset(); mPointMemory.reserve(points + segments); mTagMemory.reserve(points + segments); - mContourMemory.reserve(segments); - mContourFlagMemory.reserve(segments); + // The outline builder initializes contours_flag[n_contours] after + // incrementing n_contours and appends the final contour endpoint. + mContourMemory.reserve(segments + 1); + mContourFlagMemory.reserve(segments + 1); ft.points = mPointMemory.data(); ft.tags = mTagMemory.data(); diff --git a/src/gui/Gui.cpp b/src/gui/Gui.cpp index 10386ffd..40f5b22b 100644 --- a/src/gui/Gui.cpp +++ b/src/gui/Gui.cpp @@ -554,35 +554,16 @@ void registerBuiltinFonts() { if (fontsRegistered) return; - if (auto& ttf = Resources::loadCached("fonts/default/regular.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Default, FontStyle::Normal, FontWeight::Regular, ttf, false); - } - if (auto& ttf = Resources::loadCached("fonts/default/light.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Default, FontStyle::Normal, FontWeight::Light, ttf, false); - } - if (auto& ttf = Resources::loadCached("fonts/default/bold.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Default, FontStyle::Normal, FontWeight::Bold, ttf, false); - } - if (auto& ttf = Resources::loadCached("fonts/default/regular-italic.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Default, FontStyle::Italic, FontWeight::Regular, ttf, false); - } - if (auto& ttf = Resources::loadCached("fonts/default/light-italic.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Default, FontStyle::Italic, FontWeight::Light, ttf, false); - } - if (auto& ttf = Resources::loadCached("fonts/default/bold-italic.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Default, FontStyle::Italic, FontWeight::Bold, ttf, false); - } + std::ignore = fonts->addFontFromResource("fonts/default/regular.ttf", Font::Default, true); + std::ignore = fonts->addFontFromResource("fonts/default/light.ttf", Font::Default, true); + std::ignore = fonts->addFontFromResource("fonts/default/bold.ttf", Font::Default, true); + std::ignore = fonts->addFontFromResource("fonts/default/regular-italic.ttf", Font::Default, true); + std::ignore = fonts->addFontFromResource("fonts/default/light-italic.ttf", Font::Default, true); + std::ignore = fonts->addFontFromResource("fonts/default/bold-italic.ttf", Font::Default, true); - if (auto& ttf = Resources::loadCached("fonts/icons.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Icons, FontStyle::Normal, FontWeight::Regular, ttf, false); - } - if (auto& ttf = Resources::loadCached("fonts/emoji.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Emoji, FontStyle::Normal, FontWeight::Regular, ttf, false, - FontFlags::EnableColor); - } - if (auto& ttf = Resources::loadCached("fonts/mono/regular.ttf", true); !ttf.empty()) { - fonts->addFont(Font::Monospace, FontStyle::Normal, FontWeight::Regular, ttf, false); - } + std::ignore = fonts->addFontFromResource("fonts/icons.ttf", Font::Icons, true); + std::ignore = fonts->addFontFromResource("fonts/emoji.ttf", Font::Emoji, true); + std::ignore = fonts->addFontFromResource("fonts/mono/regular.ttf", Font::Monospace, true); fontsRegistered = true; } @@ -736,23 +717,25 @@ void Widget::computeClipRect() { void Widget::prepareHint() { if (m_hint.empty()) { - m_hintPrepared = {}; + m_hintShapedText = {}; + m_hintTextLayout = {}; return; } - Font font = Font{ Font::DefaultPlusIconsEmoji, dp(FontSize::Normal - 1) }; - m_hintPrepared = fonts->prepare(font, m_hint); + Font font = Font{ Font::DefaultPlusIconsEmoji, dp(FontSize::Normal - 1) }; + m_hintShapedText = fonts->shapeText(font, TextWithOptions{ m_hint }); + m_hintTextLayout = m_hintShapedText.layout(); } void Widget::computeHintRect() { - if (m_hintPrepared.lines.empty()) { + if (m_hintTextLayout.empty()) { m_hintRect = {}; return; } - Size textSize = m_hintPrepared.bounds().size(); + Size textSize = m_hintTextLayout.bounds().size(); Point p = m_rect.at(0.5f, 1.f); m_hintRect = p.alignedRect(textSize + Size{ 12_idp, 6_idp }, { 0.5f, 0.f }); if (m_tree && !m_tree->viewportRectangle().empty() && !m_hint.empty()) { - Size textSize = m_hintPrepared.bounds().size(); + Size textSize = m_hintTextLayout.bounds().size(); Rectangle boundingRect = m_tree->viewportRectangle(); Point p = m_rect.at(0.5f, 1.f); @@ -773,7 +756,7 @@ void Widget::computeHintRect() { m_hintRect.applyOffset(0, boundingRect.y1 - m_hintRect.y1); m_hintRect.y2 = std::min(m_hintRect.y2, boundingRect.y2); } - m_hintTextOffset = m_hintPrepared.alignLines(0.5f, 0.5f); + m_hintTextOffset = {}; } /// Returns the number of changes @@ -1234,10 +1217,8 @@ void Widget::paintFocusFrame(Canvas& canvas) const { } void Widget::paintHint(Canvas& canvas) const { - if ((m_isHintExclusive || isHintCurrent()) && !m_hintPrepared.lines.empty() && m_tree && - m_isHintVisible) { + if ((m_isHintExclusive || isHintCurrent()) && !m_hintTextLayout.empty() && m_tree && m_isHintVisible) { m_tree->requestLayer([this](Canvas& canvas) { - SizeF textSize = m_hintPrepared.bounds().size(); ColorW color = getStyleVar(hintBackgroundColor.id).value_or(Palette::white); ColorW shadowColor = getStyleVar(hintShadowColor.id).value_or(Palette::black); canvas.setFillColor(shadowColor); @@ -1245,7 +1226,7 @@ void Widget::paintHint(Canvas& canvas) const { canvas.setFillColor(color); canvas.fillRect(m_hintRect, 5._dp, m_squircleCorners); canvas.setFillColor(getStyleVar(hintTextColor.id).value_or(Palette::black)); - canvas.fillText(m_hintRect.center() + m_hintTextOffset, m_hintPrepared); + canvas.fillText(m_hintRect.center() + m_hintTextOffset, { 0.5f, 0.5f }, m_hintTextLayout); }); } } @@ -2544,7 +2525,7 @@ void Widget::setSelected(bool value) { } bool Widget::setScrollOffset(Point newOffset) { - newOffset = max(newOffset, Point(0)); + newOffset = max(newOffset, Point(0, 0)); newOffset = min(newOffset, Point(scrollSize())); return setChildrenOffset(-newOffset); } diff --git a/src/testdata/backlayer.png b/src/testdata/backlayer.png index 594e9dcb..c977c763 100644 Binary files a/src/testdata/backlayer.png and b/src/testdata/backlayer.png differ diff --git a/src/testdata/canvas-gradientType-angle.png b/src/testdata/canvas-gradientType-angular.png similarity index 100% rename from src/testdata/canvas-gradientType-angle.png rename to src/testdata/canvas-gradientType-angular.png diff --git a/src/testdata/emoji-text.png b/src/testdata/emoji-text.png index c5a511f2..ca6b15da 100644 Binary files a/src/testdata/emoji-text.png and b/src/testdata/emoji-text.png differ diff --git a/src/testdata/html-text.png b/src/testdata/html-text.png index d693fa79..dfefe58e 100644 Binary files a/src/testdata/html-text.png and b/src/testdata/html-text.png differ diff --git a/src/testdata/matrix-invariants-emoji.png b/src/testdata/matrix-invariants-emoji.png index 8495dd28..00337974 100644 Binary files a/src/testdata/matrix-invariants-emoji.png and b/src/testdata/matrix-invariants-emoji.png differ diff --git a/src/testdata/matrix-invariants-text.png b/src/testdata/matrix-invariants-text.png index eb6084fc..f58ca6df 100644 Binary files a/src/testdata/matrix-invariants-text.png and b/src/testdata/matrix-invariants-text.png differ diff --git a/src/testdata/rr-fonts-subpixel.png b/src/testdata/rr-fonts-subpixel.png index 168d624a..a9493f1a 100644 Binary files a/src/testdata/rr-fonts-subpixel.png and b/src/testdata/rr-fonts-subpixel.png differ diff --git a/src/testdata/rr-fonts.png b/src/testdata/rr-fonts.png index a2069794..ff4fefa6 100644 Binary files a/src/testdata/rr-fonts.png and b/src/testdata/rr-fonts.png differ diff --git a/src/testdata/text-layout-bidi-wrap.png b/src/testdata/text-layout-bidi-wrap.png new file mode 100644 index 00000000..cb8ffaac Binary files /dev/null and b/src/testdata/text-layout-bidi-wrap.png differ diff --git a/src/testdata/text-layout-bidi.png b/src/testdata/text-layout-bidi.png new file mode 100644 index 00000000..3dab1619 Binary files /dev/null and b/src/testdata/text-layout-bidi.png differ diff --git a/src/testdata/text-layout-canvas-styles.png b/src/testdata/text-layout-canvas-styles.png new file mode 100644 index 00000000..e45f99dd Binary files /dev/null and b/src/testdata/text-layout-canvas-styles.png differ diff --git a/src/testdata/text-layout-canvas.png b/src/testdata/text-layout-canvas.png new file mode 100644 index 00000000..3e8e75fb Binary files /dev/null and b/src/testdata/text-layout-canvas.png differ diff --git a/src/testdata/text-layout-centered.png b/src/testdata/text-layout-centered.png new file mode 100644 index 00000000..d85a377e Binary files /dev/null and b/src/testdata/text-layout-centered.png differ diff --git a/src/testdata/text-layout-ligatures.png b/src/testdata/text-layout-ligatures.png new file mode 100644 index 00000000..ee6ca80c Binary files /dev/null and b/src/testdata/text-layout-ligatures.png differ diff --git a/src/testdata/text-layout-line-height-bounds.png b/src/testdata/text-layout-line-height-bounds.png new file mode 100644 index 00000000..2a1b2ccc Binary files /dev/null and b/src/testdata/text-layout-line-height-bounds.png differ diff --git a/src/testdata/text-layout-monospace.png b/src/testdata/text-layout-monospace.png new file mode 100644 index 00000000..557e3838 Binary files /dev/null and b/src/testdata/text-layout-monospace.png differ diff --git a/src/testdata/text-layout-multiple-fonts.png b/src/testdata/text-layout-multiple-fonts.png new file mode 100644 index 00000000..96e76758 Binary files /dev/null and b/src/testdata/text-layout-multiple-fonts.png differ diff --git a/src/testdata/text-layout-selection-multiline.png b/src/testdata/text-layout-selection-multiline.png new file mode 100644 index 00000000..ac279b48 Binary files /dev/null and b/src/testdata/text-layout-selection-multiline.png differ diff --git a/src/testdata/text-subpixel-alignment.png b/src/testdata/text-subpixel-alignment.png index 600a3e9c..f4b0bd7f 100644 Binary files a/src/testdata/text-subpixel-alignment.png and b/src/testdata/text-subpixel-alignment.png differ diff --git a/src/testdata/widget-button-color.png b/src/testdata/widget-button-color.png index d14af7b8..2feaf98a 100644 Binary files a/src/testdata/widget-button-color.png and b/src/testdata/widget-button-color.png differ diff --git a/src/testdata/widget-button-disabled.png b/src/testdata/widget-button-disabled.png index fd3ef70c..85ba1327 100644 Binary files a/src/testdata/widget-button-disabled.png and b/src/testdata/widget-button-disabled.png differ diff --git a/src/testdata/widget-button-emoji.png b/src/testdata/widget-button-emoji.png index 008db17c..905a761d 100644 Binary files a/src/testdata/widget-button-emoji.png and b/src/testdata/widget-button-emoji.png differ diff --git a/src/testdata/widget-button-hovered.png b/src/testdata/widget-button-hovered.png index 65034788..905b3f7f 100644 Binary files a/src/testdata/widget-button-hovered.png and b/src/testdata/widget-button-hovered.png differ diff --git a/src/testdata/widget-button-icon.png b/src/testdata/widget-button-icon.png index 6499a31d..b9df59dc 100644 Binary files a/src/testdata/widget-button-icon.png and b/src/testdata/widget-button-icon.png differ diff --git a/src/testdata/widget-button-pressed.png b/src/testdata/widget-button-pressed.png index bece6cad..2379912d 100644 Binary files a/src/testdata/widget-button-pressed.png and b/src/testdata/widget-button-pressed.png differ diff --git a/src/testdata/widget-button.png b/src/testdata/widget-button.png index b75cc5ad..2c35b2ef 100644 Binary files a/src/testdata/widget-button.png and b/src/testdata/widget-button.png differ diff --git a/src/testdata/widget-checkbox.png b/src/testdata/widget-checkbox.png index 05e79d4b..1f46877b 100644 Binary files a/src/testdata/widget-checkbox.png and b/src/testdata/widget-checkbox.png differ diff --git a/src/testdata/widget-combobox-color.png b/src/testdata/widget-combobox-color.png index ec2bd005..60197233 100644 Binary files a/src/testdata/widget-combobox-color.png and b/src/testdata/widget-combobox-color.png differ diff --git a/src/testdata/widget-combobox-gradient.png b/src/testdata/widget-combobox-gradient.png index 18a83b3f..429dc13f 100644 Binary files a/src/testdata/widget-combobox-gradient.png and b/src/testdata/widget-combobox-gradient.png differ diff --git a/src/testdata/widget-combobox.png b/src/testdata/widget-combobox.png index 81e7294b..657f1ed1 100644 Binary files a/src/testdata/widget-combobox.png and b/src/testdata/widget-combobox.png differ diff --git a/src/testdata/widget-radiobutton.png b/src/testdata/widget-radiobutton.png index 09780674..28f22fa4 100644 Binary files a/src/testdata/widget-radiobutton.png and b/src/testdata/widget-radiobutton.png differ diff --git a/src/testdata/widget-switch.png b/src/testdata/widget-switch.png index 551cef9f..bb16891e 100644 Binary files a/src/testdata/widget-switch.png and b/src/testdata/widget-switch.png differ diff --git a/src/testdata/widget-text-autosize.png b/src/testdata/widget-text-autosize.png new file mode 100644 index 00000000..35a3ed05 Binary files /dev/null and b/src/testdata/widget-text-autosize.png differ diff --git a/src/testdata/widget-text-empty.png b/src/testdata/widget-text-empty.png new file mode 100644 index 00000000..b9444b62 Binary files /dev/null and b/src/testdata/widget-text-empty.png differ diff --git a/src/testdata/widget-text-rotated.png b/src/testdata/widget-text-rotated.png new file mode 100644 index 00000000..49e6f85c Binary files /dev/null and b/src/testdata/widget-text-rotated.png differ diff --git a/src/testdata/widget-text-wrapped.png b/src/testdata/widget-text-wrapped.png new file mode 100644 index 00000000..40fbaaaa Binary files /dev/null and b/src/testdata/widget-text-wrapped.png differ diff --git a/src/testdata/widget-text.png b/src/testdata/widget-text.png index a7b21c19..47f46fef 100644 Binary files a/src/testdata/widget-text.png and b/src/testdata/widget-text.png differ diff --git a/src/testdata/widget-togglebutton.png b/src/testdata/widget-togglebutton.png index 6dab8f5a..3a70a09b 100644 Binary files a/src/testdata/widget-togglebutton.png and b/src/testdata/widget-togglebutton.png differ diff --git a/src/widgets/Text.cpp b/src/widgets/Text.cpp index 810c96f2..ffa31892 100644 --- a/src/widgets/Text.cpp +++ b/src/widgets/Text.cpp @@ -22,6 +22,18 @@ namespace Brisk { +static TextLayoutAlignment toTextLayoutAlignment(TextAlign align) { + switch (align) { + case TextAlign::Start: + return TextLayoutAlignment::Start; + case TextAlign::Center: + return TextLayoutAlignment::Center; + case TextAlign::End: + return TextLayoutAlignment::End; + } + return TextLayoutAlignment::Start; +} + Text::Text(Construction construction, std::string text, ArgumentsView args) : Widget{ construction, nullptr }, m_text(std::move(text)) { args.apply(this); @@ -43,26 +55,26 @@ void Text::onChanged() { invalidate(); Font font = this->font(); if (!m_wordWrap && m_textAutoSize != TextAutoSize::None && !m_text.empty()) { - font.fontSize = font.fontSize = calcFontSizeFor(font, m_text); + font.fontSize = calcFontSizeFor(font, m_text); } if (m_cache.invalidate(CacheKey{ font, m_text })) { if (m_wordWrap || m_textAutoSize == TextAutoSize::None) { requestUpdateLayout(); } - m_cache2.invalidate({ m_clientRect.width() }, true); + m_cache2.invalidate({ m_clientRect.width(), toTextLayoutAlignment(m_textAlign) }, true); } else if (m_wordWrap) { - m_cache2.invalidate({ m_clientRect.width() }); + m_cache2.invalidate({ m_clientRect.width(), toTextLayoutAlignment(m_textAlign) }); } } float Text::calcFontSizeFor(const Font& font, const std::string& m_text) const { - float fontSize = m_fontSize.current; - const float refFontSize = 32.f; - Font refFont = font; - refFont.fontSize = refFontSize; - SizeF sz = fonts->bounds(refFont, utf8ToUtf32(m_text)) - .size() - .flippedIf(toOrientation(m_rotation) == Orientation::Vertical); + float fontSize = m_fontSize.current; + const float refFontSize = 32.f; + Font refFont = font; + refFont.fontSize = refFontSize; + const ShapedText document = fonts->shapeText(refFont, TextWithOptions{ m_text, m_textOptions }); + SizeF sz = + document.layout().bounds().size().flippedIf(toOrientation(m_rotation) == Orientation::Vertical); if (sz.width != 0 && sz.height != 0) { switch (m_textAutoSize) { case TextAutoSize::FitWidth: @@ -104,37 +116,41 @@ SizeF Text::measure(AvailableSize size) const { } return result; } else { - uint32_t width = size.x.valueOr(16777216.f); - PreparedText prepared = m_cache->shaped.wrap(width); - return alignInflate(prepared.bounds()).size(); + const int width = std::max(0, static_cast(size.x.valueOr(16777216.f))); + TextLayoutOptions options; + options.maxLineWidth = static_cast(width); + options.alignment = toTextLayoutAlignment(m_textAlign); + return alignInflate(m_cache->shapedText.layout(options).bounds()).size(); } } void Text::paint(Canvas& canvas) const { Widget::paint(canvas); if (m_opacity.current > 0.f) { - RectangleF inner = m_clientRect; - ColorW color = m_color.current.multiplyAlpha(m_opacity.current); - auto prepared = m_cache2->prepared; + m_cache2.invalidate({ m_clientRect.width(), toTextLayoutAlignment(m_textAlign) }); + RectangleF inner = m_clientRect; + ColorW color = m_color.current.multiplyAlpha(m_opacity.current); + const TextLayout layout = m_cache2->layout; + const PointF alignment{ toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign) }; + const RectangleF bounds = layout.bounds(); + const auto originFor = [&](RectangleF container) { + const PointF anchor = container.at(alignment.x, alignment.y); + return anchor - PointF{ bounds.x1, bounds.y1 } - PointF(bounds.size()) * alignment; + }; canvas.setFillColor(color); if (m_rotation != Rotation::NoRotation) { RectangleF rotated = RectangleF{ 0, 0, inner.width(), inner.height() }.flippedIf( toOrientation(m_rotation) == Orientation::Vertical); - Matrix m = Matrix() - .translate(-rotated.center().x, -rotated.center().y) - .rotate90(static_cast(m_rotation)) - .translate(inner.center().x, inner.center().y); - PointF offset = prepared.alignLines(toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign)); - auto&& state = canvas.saveState(); + Matrix m = Matrix() + .translate(-rotated.center().x, -rotated.center().y) + .rotate90(static_cast(m_rotation)) + .translate(inner.center().x, inner.center().y); + auto&& state = canvas.saveState(); state->transform = m; - canvas.fillText(rotated.at(toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign)) + offset, - prepared); + canvas.fillText(originFor(rotated), layout); } else { - PointF offset = prepared.alignLines(toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign)); - prepared.updateCaretData(); - offset += inner.at(toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign)); - canvas.fillText(offset, prepared); + canvas.fillText(originFor(inner), layout); } } } @@ -148,19 +164,24 @@ void Text::onFontChanged() { } Text::Cached Text::updateCache(const CacheKey& key) { - PreparedText shaped = fonts->prepare(key.font, TextWithOptions(key.text, m_textOptions)); - return { std::move(shaped) }; + ShapedText shapedText = fonts->shapeText(key.font, TextWithOptions(key.text, m_textOptions)); + return { std::move(shapedText) }; } Text::Cached2 Text::updateCache2(const CacheKey2& key) { m_cache.update(); - auto prepared = m_cache->shaped.wrap(m_wordWrap ? key.width : 16777216.f); - SizeF textSize = prepared.bounds().size(); - textSize = max(textSize, SizeF{ 0, fonts->metrics(m_cache.key().font).vertBounds() }); - return { textSize, std::move(prepared) }; + TextLayoutOptions options; + options.maxLineWidth = m_wordWrap ? static_cast(key.width) : HUGE_VALF; + options.alignment = key.alignment; + const TextLayout layout = m_cache->shapedText.layout(options); + SizeF textSize = layout.bounds().size(); + const TextLine line = layout.line(0); + textSize = max(textSize, SizeF{ 0, line.ascender - line.descender }); + return { textSize, layout }; } void BackStrikedText::paint(Canvas& canvas) const { + Widget::paint(canvas); ColorW color = m_color.current.multiplyAlpha(m_opacity.current); canvas.setFillColor(color); canvas.setFont(font()); @@ -176,7 +197,6 @@ void BackStrikedText::paint(Canvas& canvas) const { canvas.fillRect(r1); if (r2.width() > 0) canvas.fillRect(r2); - Widget::paint(canvas); } Rc BackStrikedText::cloneThis() const { @@ -192,7 +212,7 @@ void HoveredDescription::paint(Canvas& canvas) const { } if (m_lastChange && frameStartTime - *m_lastChange > hoverDelay) { canvas.setFont(font()); - canvas.setFillColor(m_color.current); + canvas.setFillColor(m_color.current.multiplyAlpha(m_opacity.current)); canvas.fillText(*m_cachedText, m_clientRect, PointF(toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign))); } diff --git a/src/widgets/TextEditor.cpp b/src/widgets/TextEditor.cpp index f4f5c419..b71166af 100644 --- a/src/widgets/TextEditor.cpp +++ b/src/widgets/TextEditor.cpp @@ -35,6 +35,38 @@ static std::u32string normalizeCompose(std::u32string str) { return str; } +// Determines the caret affinity a cursor position should have, based purely on the +// (already edited) text buffer. This mirrors the rule used for horizontal caret +// movement in moveCursor(): a caret that sits immediately before a paragraph +// separator stays attached to the end of the line that precedes it (Upstream), +// while a caret that sits immediately after one belongs to the beginning of the +// following line (Downstream). Every code path that moves the cursor by directly +// editing the text (typing, backspace, delete, paste, deleting a selection) must +// recompute affinity this way instead of assuming Downstream, otherwise the caret +// can end up rendered on the wrong visual line at a hard paragraph break. +static CaretAffinity caretAffinityForPosition(const std::u32string& text, uint32_t cursor) { + const auto isParagraphSeparator = [](char32_t character) { + switch (character) { + case U'\n': + case U'\r': + case U'\u001C': + case U'\u001D': + case U'\u001E': + case U'\u0085': + case U'\u2029': + return true; + default: + return false; + } + }; + + if (cursor > 0 && isParagraphSeparator(text[cursor - 1])) + return CaretAffinity::Downstream; + if (cursor < text.size() && isParagraphSeparator(text[cursor])) + return CaretAffinity::Upstream; + return CaretAffinity::Downstream; +} + TextEditor::TextEditor(Construction construction, ArgumentsView args) : Base(construction, nullptr) { m_tabStop = true; @@ -113,7 +145,31 @@ void TextEditor::createContextMenu() { } Range TextEditor::selection() const { - return { std::min(cursor, cursor + selectedLength), std::max(cursor, cursor + selectedLength) }; + const int32_t cursorPosition = static_cast(cursor); + const int32_t selectionEnd = cursorPosition + selectedLength; + return { static_cast(std::min(cursorPosition, selectionEnd)), + static_cast(std::max(cursorPosition, selectionEnd)) }; +} + +CaretIndex TextEditor::caretIndex() const { + return m_shapedText.caretFromCharacter(std::min(cursor, m_shapedText.characterCount()), m_caretAffinity); +} + +void TextEditor::setCaretIndex(CaretIndex caret) { + m_caretAffinity = caret.affinity; + cursor = m_shapedText.characterFromCaret(caret); +} + +CaretIndex TextEditor::caretAtPoint(PointF pt) const { + if (m_textLayout.lineCount() == 0) { + return {}; + } + return m_textLayout.hitTest(PointF(pt) - textOrigin()); +} + +PointF TextEditor::textOrigin() const { + PointF alignment{ toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign) }; + return PointF(m_clientRect.at(alignment)) - PointF(m_visibleOffset); } void TextEditor::moveCursor(MoveCursor move, bool select) { @@ -122,51 +178,67 @@ void TextEditor::moveCursor(MoveCursor move, bool select) { switch (move) { case Up: case Down: { - uint32_t grapheme = m_preparedText.characterToGrapheme(cursor); - uint32_t line = m_preparedText.graphemeToLine(grapheme); - float offsetx = m_preparedText.caretPositions[grapheme]; - if (line == UINT32_MAX || (line == 0 && move == Up) || - (line == m_preparedText.lines.size() - 1 && move == Down)) + CaretIndex current = caretIndex(); + CaretPosition position = m_textLayout.caretPosition(current); + const int direction = move == Up ? -1 : 1; + const size_t line = m_textLayout.lineForCaret(current); + if (m_textLayout.lineCount() == 0 || (direction < 0 && line == 0) || + (direction > 0 && line + 1 >= m_textLayout.lineCount())) break; - line += move == Down ? +1 : -1; - grapheme = m_preparedText.caretToGrapheme(line, offsetx); - cursor = m_preparedText.graphemeToCharacter(grapheme); + if (!m_preferredCaretX) + m_preferredCaretX = position.x; + setCaretIndex(m_textLayout.moveCaretVertically(current, *m_preferredCaretX, direction)); break; } case Right: case Left: { - uint32_t grapheme = m_preparedText.characterToGrapheme(cursor); - if ((grapheme == 0 && move == Left) || - (grapheme == m_preparedText.graphemeBoundaries.size() - 1 && move == Right)) + if (!select && selectedLength != 0) { + const Range currentSelection = selection(); + cursor = move == Left ? currentSelection.min : currentSelection.max; + selectedLength = 0; + m_caretAffinity = caretAffinityForPosition(m_cachedText, cursor); + m_preferredCaretX.reset(); + break; + } + uint32_t grapheme = m_shapedText.characterToGrapheme(cursor); + if ((grapheme == 0 && move == Left) || (grapheme == m_shapedText.graphemeCount() && move == Right)) break; grapheme += move == Right ? +1 : -1; - cursor = m_preparedText.graphemeToCharacter(grapheme); + CaretAffinity affinity = move == Left ? CaretAffinity::Upstream : CaretAffinity::Downstream; + if (m_shapedText.isParagraphSeparator(grapheme)) + affinity = CaretAffinity::Upstream; + else if (grapheme > 0 && m_shapedText.isParagraphSeparator(grapheme - 1)) + affinity = CaretAffinity::Downstream; + setCaretIndex({ grapheme, affinity }); + m_preferredCaretX.reset(); break; } case LineBeginning: case LineEnd: { - uint32_t grapheme = m_preparedText.characterToGrapheme(cursor); - uint32_t line = m_preparedText.graphemeToLine(grapheme); - if (line == UINT32_MAX) + const size_t line = m_textLayout.lineForCaret(caretIndex()); + if (line >= m_textLayout.lineCount()) break; - cursor = m_preparedText.graphemeToCharacter(move == LineBeginning - ? m_preparedText.lines[line].graphemeRange.min - : m_preparedText.lines[line].graphemeRange.max - 1); + setCaretIndex(move == LineBeginning ? m_textLayout.lineBeginning(line) : m_textLayout.lineEnd(line)); + m_preferredCaretX.reset(); break; } case TextBeginning: - cursor = 0; + cursor = 0; + m_caretAffinity = CaretAffinity::Downstream; + m_preferredCaretX.reset(); break; case TextEnd: - cursor = m_cachedText.size(); + cursor = m_cachedText.size(); + m_caretAffinity = CaretAffinity::Upstream; + m_preferredCaretX.reset(); break; } if (select) { - selectedLength += oldCursor - cursor; + selectedLength += static_cast(oldCursor) - static_cast(cursor); } else { selectedLength = 0; } @@ -186,89 +258,92 @@ void TextEditor::paint(Canvas& canvas) const { PointF alignment{ toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign) }; - ColorW textColor = m_color.current; - m_alignmentOffset = m_preparedText.alignLines(alignment); + ColorW textColor = m_color.current; if (isPlaceholder) textColor = textColor.multiplyAlpha(0.5f); canvas.setFillColor(ColorW(Palette::Standard::indigo).multiplyAlpha(isFocused() ? 0.85f : 0.5f)); - Point pos = m_clientRect.at(alignment) + Point(m_alignmentOffset - m_visibleOffset); - canvas.fillTextSelection(pos, m_preparedText, selection); + Point pos = Point(textOrigin()); + canvas.fillTextSelection(pos, alignment, m_textLayout, selection); canvas.setFillColor(textColor); - canvas.fillText(pos, m_preparedText); + canvas.fillText(pos, alignment, m_textLayout); if (isFocused() && m_blinkState && !isDisabled()) { - uint32_t caretGrapheme = - m_preparedText.characterToGrapheme(std::clamp(cursor, 0u, uint32_t(m_cachedText.size()))); - - uint32_t lineIndex = m_preparedText.graphemeToLine(caretGrapheme); - if (lineIndex != UINT32_MAX) { - const auto& line = m_preparedText.lines[lineIndex]; - Rectangle caretRect(m_clientRect.at(alignment) + Point(m_alignmentOffset - m_visibleOffset) + - Point(m_preparedText.caretPositions[caretGrapheme], line.baseline) + - Point(0, -line.ascDesc.ascender), - Size(1_idp, line.ascDesc.height())); + const RectangleF caret = m_textLayout.caretRect(caretIndex(), 1_dp); + if (!caret.empty()) { + const RectangleF bounds = m_textLayout.bounds(); + const PointF offset = PointF(pos) - PointF(bounds.size()) * alignment; + const RectangleF translated{ caret.x1 + offset.x, caret.y1 + offset.y, caret.x2 + offset.x, + caret.y2 + offset.y }; canvas.setFillColor(textColor); - canvas.fillRect(caretRect, 0.f); + canvas.fillRect(translated, 0.f); } } } void TextEditor::normalizeCursor(uint32_t textLen) { - uint32_t newCursor = std::clamp(cursor, 0u, textLen); - uint32_t newSelectedLength = std::clamp(cursor + selectedLength, 0u, textLen) - cursor; - if (newCursor != cursor || newSelectedLength != selectedLength) { - cursor = newCursor; + const int32_t length = static_cast(textLen); + const int32_t cursorPosition = static_cast(cursor); + const int32_t selectionEnd = cursorPosition + selectedLength; + const int32_t newCursor = std::clamp(cursorPosition, 0, length); + const int32_t newSelectionEnd = std::clamp(selectionEnd, 0, length); + const int32_t newSelectedLength = newSelectionEnd - newCursor; + if (newCursor != static_cast(cursor) || newSelectedLength != selectedLength) { + cursor = static_cast(newCursor); selectedLength = newSelectedLength; selectionChanged(); } } -void TextEditor::makeCursorVisible(uint32_t textLen) { - if (!m_preparedText.hasCaretData() || m_preparedText.caretPositions.size() <= 1) { +void TextEditor::makeCursorVisible() { + if (m_textLayout.lineCount() == 0) { m_visibleOffset = { 0, 0 }; invalidate(); return; } - SizeF bounds = m_preparedText.bounds().size(); + SizeF bounds = m_textLayout.bounds().size(); if (std::ceil(bounds.width) < m_clientRect.width() && std::ceil(bounds.height) < m_clientRect.height()) { m_visibleOffset = { 0, 0 }; invalidate(); return; } - uint32_t grapheme = m_preparedText.characterToGrapheme(cursor); - uint32_t lineIndex = m_preparedText.graphemeToLine(grapheme); - if (lineIndex == UINT32_MAX) + const CaretPosition caret = m_textLayout.caretPosition(caretIndex()); + const uint32_t lineIndex = caret.line; + if (lineIndex >= m_textLayout.lineCount()) return; - const auto& line = m_preparedText.lines[lineIndex]; - float caretx = m_preparedText.caretPositions[grapheme]; - float carety = line.baseline; - - m_visibleOffset -= m_alignmentOffset; - - if (caretx < m_visibleOffset.x) - m_visibleOffset.x = std::floor(caretx) - 2_idp; - else if (caretx > m_visibleOffset.x + m_clientRect.width()) - m_visibleOffset.x = std::ceil(caretx) - m_clientRect.width() + 2_idp; - if (carety - line.ascDesc.ascender < m_visibleOffset.y) - m_visibleOffset.y = std::floor(carety - line.ascDesc.ascender) - 2_idp; - else if (carety + line.ascDesc.descender > m_visibleOffset.y + m_clientRect.height()) - m_visibleOffset.y = std::ceil(carety + line.ascDesc.descender) - m_clientRect.height() + 2_idp; - - m_visibleOffset += m_alignmentOffset; - m_visibleOffset.x = std::max(0, m_visibleOffset.x); - m_visibleOffset.y = std::max(0, m_visibleOffset.y); + const Rectangle caretRect = m_textLayout.caretRect(caretIndex(), 1_dp).roundOutward(); + const PointF alignment{ toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign) }; + const PointF layoutOffset = + PointF(m_clientRect.at(alignment)) - PointF(m_visibleOffset) - PointF(bounds) * alignment; + const RectangleF visibleCaret{ caretRect.x1 + layoutOffset.x, caretRect.y1 + layoutOffset.y, + caretRect.x2 + layoutOffset.x, caretRect.y2 + layoutOffset.y }; + + const auto keepVisible = [](float caretStart, float caretEnd, float clientStart, float clientEnd, + int32_t& offset) { + if (caretStart < clientStart && caretEnd > clientEnd) { + // An oversized caret cannot fit; show its center in the client rectangle. + offset += static_cast( + std::lround((caretStart + caretEnd) * 0.5f - (clientStart + clientEnd) * 0.5f)); + } else if (caretStart < clientStart) { + offset += static_cast(std::lround(caretStart - clientStart)); + } else if (caretEnd > clientEnd) { + offset += static_cast(std::lround(caretEnd - clientEnd)); + } + offset = std::max(0, offset); + }; + + keepVisible(visibleCaret.x1, visibleCaret.x2, m_clientRect.x1, m_clientRect.x2, m_visibleOffset.x); + keepVisible(visibleCaret.y1, visibleCaret.y2, m_clientRect.y1, m_clientRect.y2, m_visibleOffset.y); + invalidate(); } uint32_t TextEditor::caretToOffset(PointF pt) const { - if (!m_preparedText.hasCaretData() || m_preparedText.caretPositions.size() == 1) { + if (m_textLayout.lineCount() == 0) { return 0; } - PointF alignment{ toFloatAlign(m_textAlign), toFloatAlign(m_textVerticalAlign) }; - return m_preparedText.graphemeToCharacter(m_preparedText.caretToGrapheme( - Point(pt) - (m_clientRect.at(alignment) + Point(m_alignmentOffset - m_visibleOffset)))); + return m_shapedText.characterFromCaret(caretAtPoint(pt)); } static bool char_is_alphanum(char32_t ch) { @@ -279,21 +354,23 @@ static bool char_is_alphanum(char32_t ch) { void TextEditor::selectWordAtCursor() { std::u32string text = utf8ToUtf32(m_text); normalizeCursor(text.size()); - const int cursorPos = cursor; - for (int i = cursorPos;; i--) { - if (i < 0 || !char_is_alphanum(text[i])) { - cursor = i + 1; + const int32_t textLength = static_cast(text.size()); + const int32_t cursorPos = static_cast(cursor); + for (int32_t i = cursorPos - 1;; i--) { + if (i < 0 || !char_is_alphanum(text[static_cast(i)])) { + cursor = static_cast(i + 1); break; } } - for (int i = cursorPos;; i++) { - if (i >= text.size() || !char_is_alphanum(text[i])) { - selectedLength = i - cursor; + for (int32_t i = cursorPos;; i++) { + if (i >= textLength || !char_is_alphanum(text[static_cast(i)])) { + selectedLength = i - static_cast(cursor); break; } } - selectedLength = -selectedLength; - cursor = cursor - selectedLength; + selectedLength = -selectedLength; + cursor = static_cast(static_cast(cursor) - selectedLength); + m_caretAffinity = caretAffinityForPosition(text, cursor); selectionChanged(); normalizeCursor(text.size()); } @@ -322,19 +399,23 @@ void TextEditor::onEvent(Event& event) { text = utf8ToUtf32(m_text); resetBlinking(); focus(); - cursor = caretToOffset(Point(*event.as()->downPoint)); + m_preferredCaretX.reset(); + setCaretIndex(caretAtPoint(Point(*event.as()->downPoint))); + cursor = std::min(cursor, static_cast(text.size())); selectedLength = 0; selectionChanged(); normalizeCursor(text.size()); - m_startCursorDragging = caretToOffset(Point(*event.as()->downPoint)); + m_startCursorDragging = static_cast(cursor); event.stopPropagation(); } break; case DragEvent::Dragging: { text = utf8ToUtf32(m_text); resetBlinking(); - const int endCursor = caretToOffset(Point(event.as()->point)); - selectedLength = m_startCursorDragging - endCursor; - cursor = endCursor; + const CaretIndex endCaret = caretAtPoint(Point(event.as()->point)); + const int endCursor = static_cast(m_shapedText.characterFromCaret(endCaret)); + m_caretAffinity = endCaret.affinity; + selectedLength = m_startCursorDragging - endCursor; + cursor = endCursor; selectionChanged(); normalizeCursor(text.size()); invalidate(); @@ -360,7 +441,7 @@ void TextEditor::onEvent(Event& event) { case KeyCode::A: if ((e->mods & KeyModifiers::Regular) == KeyModifiers::ControlOrCommand) { selectAll(text); - makeCursorVisible(m_cachedText.size()); + makeCursorVisible(); event.stopPropagation(); } break; @@ -387,25 +468,25 @@ void TextEditor::onEvent(Event& event) { case KeyCode::Up: if (m_multiline) { moveCursor(MoveCursor::Up, (e->mods & KeyModifiers::Regular) == KeyModifiers::Shift); - makeCursorVisible(m_cachedText.size()); + makeCursorVisible(); event.stopPropagation(); } break; case KeyCode::Down: if (m_multiline) { moveCursor(MoveCursor::Down, (e->mods & KeyModifiers::Regular) == KeyModifiers::Shift); - makeCursorVisible(m_cachedText.size()); + makeCursorVisible(); event.stopPropagation(); } break; case KeyCode::Left: moveCursor(MoveCursor::Left, (e->mods & KeyModifiers::Regular) == KeyModifiers::Shift); - makeCursorVisible(m_cachedText.size()); + makeCursorVisible(); event.stopPropagation(); break; case KeyCode::Right: moveCursor(MoveCursor::Right, (e->mods & KeyModifiers::Regular) == KeyModifiers::Shift); - makeCursorVisible(m_cachedText.size()); + makeCursorVisible(); event.stopPropagation(); break; case KeyCode::Home: @@ -413,24 +494,30 @@ void TextEditor::onEvent(Event& event) { ? MoveCursor::TextBeginning : MoveCursor::LineBeginning, (e->mods & KeyModifiers::Regular) == KeyModifiers::Shift); - makeCursorVisible(m_cachedText.size()); + makeCursorVisible(); event.stopPropagation(); break; case KeyCode::End: moveCursor((e->mods & KeyModifiers::Regular) == KeyModifiers::Control ? MoveCursor::TextEnd : MoveCursor::LineEnd, (e->mods & KeyModifiers::Regular) == KeyModifiers::Shift); - makeCursorVisible(m_cachedText.size()); + makeCursorVisible(); event.stopPropagation(); break; case KeyCode::Backspace: if (selectedLength) { deleteSelection(text); } else { - // delete one codepoint + // Delete one codepoint, except that CRLF is one logical paragraph + // separator and must be deleted atomically. if (cursor > 0) { - text.erase(cursor - 1, 1); - cursor = cursor - 1; // no need to align + const uint32_t eraseBegin = + cursor >= 2 && text[cursor - 2] == U'\r' && text[cursor - 1] == U'\n' + ? cursor - 2 + : cursor - 1; + text.erase(eraseBegin, cursor - eraseBegin); + cursor = eraseBegin; + m_caretAffinity = caretAffinityForPosition(text, cursor); selectionChanged(); } } @@ -443,11 +530,13 @@ void TextEditor::onEvent(Event& event) { } else { // delete whole grapheme if (cursor < text.size()) { + const auto graphemeBoundaries = m_shapedText.graphemeBoundaries(); auto endOfGrapheme = - std::upper_bound(m_preparedText.graphemeBoundaries.begin(), - m_preparedText.graphemeBoundaries.end(), cursor); - if (endOfGrapheme != m_preparedText.graphemeBoundaries.end()) + std::upper_bound(graphemeBoundaries.begin(), graphemeBoundaries.end(), cursor); + if (endOfGrapheme != graphemeBoundaries.end()) { text.erase(cursor, *endOfGrapheme - cursor); + m_caretAffinity = caretAffinityForPosition(text, cursor); + } selectionChanged(); } } @@ -491,7 +580,17 @@ constexpr std::u32string_view newLine = U"\n"; #endif static std::u32string newLinesConvert(std::u32string text, std::u32string_view nl) { - return replaceAll(replaceAll(std::move(text), U"\r\n", nl), U"\n", nl); + std::u32string result; + result.reserve(text.size()); + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] == U'\r' && i + 1 < text.size() && text[i + 1] == U'\n') + ++i; + if (text[i] == U'\r' || text[i] == U'\n') + result += nl; + else + result += text[i]; + } + return result; } static std::u32string newLinesToNative(std::u32string text) { @@ -520,8 +619,8 @@ void TextEditor::cutToClipboard() { } void TextEditor::selectAll(const std::u32string& text) { - cursor = text.size(); - selectedLength = -text.size(); + cursor = static_cast(text.size()); + selectedLength = -static_cast(text.size()); selectionChanged(); } @@ -536,8 +635,9 @@ void TextEditor::deleteSelection(std::u32string& text) { if (selectedLength) { const Range selection = this->selection(); text.erase(selection.min, selection.distance()); - cursor = selection.min; - selectedLength = 0; + cursor = selection.min; + selectedLength = 0; + m_caretAffinity = caretAffinityForPosition(text, cursor); selectionChanged(); } } @@ -548,8 +648,9 @@ void TextEditor::pasteFromClipboard(std::u32string& text) { std::u32string t32 = utf8ToUtf32(*t); t32 = newLinesToInternal(std::move(t32)); text.insert(cursor, t32); - cursor = cursor + t32.size(); - selectedLength = 0; + cursor = cursor + t32.size(); + selectedLength = 0; + m_caretAffinity = caretAffinityForPosition(text, cursor); selectionChanged(); } } @@ -574,16 +675,20 @@ void TextEditor::cutToClipboard(std::u32string& text) { } void TextEditor::updateGraphemes() { - m_preparedText = fonts->prepare( - m_cachedFont, TextWithOptions{ m_text.empty() ? utf8ToUtf32(m_placeholder) : m_cachedText, - m_multiline ? TextOptions::Default : TextOptions::SingleLine }); - - m_preparedText.updateCaretData(); + const TextOptions options = m_multiline ? TextOptions::Default : TextOptions::SingleLine; + m_shapedText = fonts->shapeText( + m_cachedFont, TextWithOptions{ m_text.empty() ? utf8ToUtf32(m_placeholder) : m_cachedText, options }); + TextLayoutOptions layoutOptions; + layoutOptions.maxLineWidth = std::max(0.f, static_cast(m_clientRect.width())); + layoutOptions.alignment = TextLayoutAlignment::Start; + m_textLayout = m_shapedText.layout(layoutOptions); + m_cachedLayoutWidth = layoutOptions.maxLineWidth; } void TextEditor::updateState() { invalidate(); - std::u32string text32 = utf8ToUtf32(m_text); + std::u32string text32 = utf8ToUtf32(m_text); + const uint32_t textLength = static_cast(text32.size()); if (m_passwordChar) { std::fill(text32.begin(), text32.end(), m_passwordChar); @@ -592,8 +697,12 @@ void TextEditor::updateState() { m_cachedText = std::move(text32); m_cachedFont = font(); updateGraphemes(); + } else if (m_textLayout.lineCount() == 0 || + m_cachedLayoutWidth != static_cast(m_clientRect.width())) { + updateGraphemes(); } - makeCursorVisible(m_cachedText.size()); + normalizeCursor(textLength); + makeCursorVisible(); } void TextEditor::setTextInternal(std::string text) { @@ -611,7 +720,8 @@ void TextEditor::onLayoutUpdated() { void TextEditor::typeCharacter(std::u32string& text, char32_t character) { deleteSelection(text); text.insert(text.begin() + cursor, character); - cursor = cursor + 1; // no need to align + cursor = cursor + 1; // no need to align + m_caretAffinity = caretAffinityForPosition(text, cursor); selectionChanged(); setTextInternal(utf32ToUtf8(normalizeCompose(text))); } diff --git a/src/widgets/Widgets_test.cpp b/src/widgets/Widgets_test.cpp index 8d5d7b78..a70c2831 100644 --- a/src/widgets/Widgets_test.cpp +++ b/src/widgets/Widgets_test.cpp @@ -22,7 +22,6 @@ #include #include #include -#include "Catch2Utils.hpp" #include "../graphics/VisualTests.hpp" #include #include @@ -113,6 +112,36 @@ TEST_CASE("Widget Text") { widgetTest("widget-text", rcnew Text{ "Text" }); } +TEST_CASE("Widget Text wrapped") { + widgetTest("widget-text-wrapped", + rcnew Text{ + "A long piece of text that must wrap across several lines", + wordWrap = true, + maxWidth = 220_dpx, + backgroundColor = Palette::Standard::indigo.multiplyAlpha(0.1f), + }, + {}, { 240, 200 }); +} + +TEST_CASE("Widget Text aligned and rotated") { + widgetTest("widget-text-rotated", + rcnew Text{ "Rotated", rotation = Rotation::Rotate90, textAlign = TextAlign::Center, + textVerticalAlign = TextAlign::Center }, + {}, { 160, 240 }); +} + +TEST_CASE("Widget Text autosize and empty") { + widgetTest("widget-text-autosize", + rcnew Text{ + "Autosized", + textAutoSize = TextAutoSize::FitSize, + minDimensions = { 240_dpx, 80_dpx }, + maxDimensions = { 240_dpx, 80_dpx }, + }, + {}, { 240, 80 }); + widgetTest("widget-text-empty", rcnew Text{ "", wordWrap = true }); +} + TEST_CASE("Widget Button") { widgetTest("widget-button", rcnew Button{ rcnew Text{ "Button" } }); } @@ -271,7 +300,7 @@ TEST_CASE("Widget Shadow") { using namespace std::literals::chrono_literals; struct WidgetAnimation { - WebpAnimationEncoder anim; + WebpAnimationEncoder anim{ std::nullopt, true }; InputQueue input; WidgetTree tree{ &input }; OffscreenCanvas offscreen; @@ -444,8 +473,8 @@ TEST_CASE("Button states animation") { TEST_CASE("Text wordWrap animation") { WidgetAnimation animation({ 288, 192 }, true); - std::ignore = fonts->addFontFromFile("Noto", FontStyle::Normal, FontWeight::Regular, - PROJECT_SOURCE_DIR "/resources/fonts/GoNotoCurrent-Regular.ttf"); + std::ignore = + fonts->addFontFromFile(PROJECT_SOURCE_DIR "/resources/fonts/GoNotoKurrent-Regular.ttf", "Noto"); float val; BindingRegistration val_r(&val, nullptr); diff --git a/src/window/PlatformWindow_Linux.cpp b/src/window/PlatformWindow_Linux.cpp index d6f14835..90f9a8c5 100644 --- a/src/window/PlatformWindow_Linux.cpp +++ b/src/window/PlatformWindow_Linux.cpp @@ -243,7 +243,7 @@ bool PlatformWindow::cursorInContentArea() const { glfwGetCursorPos(m_data->win, &cur.x, &cur.y); glfwGetWindowSize(m_data->win, &size.x, &size.y); glfwGetWindowPos(m_data->win, &pos.x, &pos.y); - return RectangleF(pos, size).contains(PointF(cur.x)); + return RectangleF(pos, size).contains(PointF(cur)); } namespace Internal { diff --git a/src/window/WindowApplication.cpp b/src/window/WindowApplication.cpp index 04b8554a..f15f9619 100644 --- a/src/window/WindowApplication.cpp +++ b/src/window/WindowApplication.cpp @@ -150,7 +150,6 @@ void WindowApplication::renderWindows() { } } afterRenderQueue->process(); - fonts->garbageCollectCache(); std::this_thread::sleep_until(stopTime); } diff --git a/vcpkg.json b/vcpkg.json index 1867d44b..0a3e72f1 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -42,6 +42,9 @@ { "name": "harfbuzz" }, + { + "name": "libunibreak" + }, { "name": "libjpeg-turbo" }, @@ -61,6 +64,9 @@ { "name": "spdlog" }, + { + "name": "sheenbidi" + }, { "name": "stb" },