From 7f2a31d4accf147bd7fbb89849729723014c9c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Se=C5=86ko?= Date: Wed, 29 Oct 2025 00:38:57 +0200 Subject: [PATCH] Better handle text to glyph edge cases * multiple char/single glyph ligatures * manual kerning offsets * letters with complex diacritic marks --- src/libnrtype/Layout-TNG.h | 4 +- src/text-chemistry.cpp | 67 +++++-- testfiles/cli_tests/CMakeLists.txt | 38 +++- .../cli_tests/testcases/text_to_glyph.svg | 173 ++++++++++++++++++ .../testcases/text_to_glyph_expected.svgz | Bin 0 -> 15010 bytes 5 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 testfiles/cli_tests/testcases/text_to_glyph.svg create mode 100644 testfiles/cli_tests/testcases/text_to_glyph_expected.svgz diff --git a/src/libnrtype/Layout-TNG.h b/src/libnrtype/Layout-TNG.h index b7b025c8ee..1289ab82e7 100644 --- a/src/libnrtype/Layout-TNG.h +++ b/src/libnrtype/Layout-TNG.h @@ -805,7 +805,6 @@ private: Path const *_path_fitted = nullptr; public: - struct Glyph; struct Character; struct Span; struct Chunk; @@ -1009,6 +1008,9 @@ public: bool operator>= (iterator const &other) const {return _char_index >= other._char_index;} + int glyphIndex() const { return _glyph_index; } + bool hasGlyph() const { return _glyph_index >= 0; } + /* **** visual-oriented methods **** */ //glyphs diff --git a/src/text-chemistry.cpp b/src/text-chemistry.cpp index 1c02845d8e..30e4b9af8a 100644 --- a/src/text-chemistry.cpp +++ b/src/text-chemistry.cpp @@ -21,10 +21,12 @@ #include "desktop.h" #include "document-undo.h" #include "document.h" +#include "inkscape-application.h" #include "inkscape.h" #include "message-stack.h" #include "preferences.h" #include "selection.h" +#include "svg/svg.h" #include "text-chemistry.h" #include "text-editing.h" @@ -556,12 +558,11 @@ text_unflow () void text_to_glyphs() { - auto desktop = SP_ACTIVE_DESKTOP; - auto selection = desktop->getSelection(); + auto doc = SP_ACTIVE_DOCUMENT; + auto selection = InkscapeApplication::instance()->get_active_selection(); std::vector results; std::vector to_delete; - auto doc = desktop->getDocument(); auto xml_doc = doc->getReprDoc(); for(auto item : selection->items()) { @@ -575,7 +576,7 @@ text_to_glyphs() auto const &layout = text->layout; auto iter = layout.end(); while (iter != layout.begin()) { - + auto end = iter; // Glyph index may not be zero leading to an infinite loop // if we don't test this here... (see issue #4767). if (!iter.prevCharacter()) { @@ -585,8 +586,42 @@ text_to_glyphs() if (layout.isWhitespace(iter)) continue; - auto str = Glib::ustring(1, layout.characterAt(iter)); - auto point = layout.characterAnchorPoint(iter); + auto it_next = iter; + int last_glyph = iter.glyphIndex(); + while (it_next.prevCharacter()) { + // multiple characters single glyph ligature + bool multi_char_glyph = it_next.glyphIndex() == last_glyph; + // In some cases diacritics are separate glyphs and separate characters, but they can not + // be used independently without base character. + // Depends on specific font. + bool helper_char = !layout.isCursorPosition(iter); + if (multi_char_glyph || helper_char) { + iter = it_next; + } else { + break; + } + last_glyph = it_next.glyphIndex(); + } + // multiple glyphs from single character, get first glyph + while (iter.glyphIndex() > 0 && layout.glyphs()[iter.glyphIndex() - 1].in_character == + layout.glyphs()[iter.glyphIndex()].in_character) { + iter.prevGlyph(); + } + + Glib::ustring str; + for (auto char_iter = iter; char_iter < end;) { + if (layout.isWhitespace(char_iter)) { + break; + } + str.push_back(layout.characterAt(char_iter)); + if (!char_iter.nextCharacter()) { + break; // should not happen + } + } + if (!iter.hasGlyph()) { + continue; + } + auto glyph = layout.glyphs()[iter.glyphIndex()]; SPObject *tspan = nullptr; layout.getSourceOfCharacter(iter, &tspan); @@ -603,13 +638,21 @@ text_to_glyphs() } result_style->merge(text->style); result_style->text_anchor.read("start"); + // reset layout properties which are already included in glyph transform + result_style->writing_mode = SP_CSS_WRITING_MODE_LR_TB; + result_style->direction = SP_CSS_DIRECTION_LTR; Glib::ustring glyph_style = result_style->writeIfDiff(text->parent->style); delete result_style; new_node->setAttributeOrRemoveIfEmpty("style", glyph_style); - new_node->setAttributeOrRemoveIfEmpty("transform", text->getAttribute("transform")); - new_node->setAttributeSvgDouble("x", point[Geom::X]); - new_node->setAttributeSvgDouble("y", point[Geom::Y]); + auto const &glyph_span = glyph.span(&layout); + auto inverse_scale = 1.0 / glyph_span.font_size; + // not ideal, assumes that in case of 1 character -> n glyphs, the first glyph is the base glyph + // and has normal position + Geom::Affine transform = + Geom::Scale(inverse_scale, -inverse_scale) // prevent font size from being applied twice + * glyph.transform(layout) * text->transform; + new_node->setAttributeOrRemoveIfEmpty("transform", sp_svg_transform_write(transform)); new_node->appendChild(xml_doc->createTextNode(str.c_str())); // Store the new object for the selection and prepare for the next glyph @@ -627,8 +670,10 @@ text_to_glyphs() } if (results.empty()) { - desktop->messageStack()->flash(Inkscape::WARNING_MESSAGE, - _("Select text(s) to convert to glyphs.")); + auto desktop = SP_ACTIVE_DESKTOP; + if (desktop) { + desktop->messageStack()->flash(Inkscape::WARNING_MESSAGE, _("Select text(s) to convert to glyphs.")); + } } else { DocumentUndo::done(doc, _("Convert text to glyphs"), INKSCAPE_ICON("text-convert-to-regular")); selection->setList(results); diff --git a/testfiles/cli_tests/CMakeLists.txt b/testfiles/cli_tests/CMakeLists.txt index 03701d72bf..2d9f9202b5 100644 --- a/testfiles/cli_tests/CMakeLists.txt +++ b/testfiles/cli_tests/CMakeLists.txt @@ -12,6 +12,9 @@ # FUZZ_PERCENTAGE - maximum allowed normalized root-mean-squared distance between compared images # RASTER_DPI - DPI setting for rasterizing vector formats before root-mean-squared comparison # PARAMETERS - additional command line parameters to pass to Inkscape +# REFERENCE_INKSCAPE - rasterize REFERENCE_FILENAME using inkscape +# Avoid this when possible! Preferably reference should be a raster image and when that's not +# practical rasterized using third party rasterizer instead of inkscape. # # Pass/fail criteria: # PASS_FOR_OUTPUT - pass if output matches the given value, otherwise fail @@ -50,7 +53,8 @@ endfunction(add_cli_test) # function(add_cli_test_run fixture testname) # parse arguments - set(oneValueArgs INPUT_FILENAME OUTPUT_FILENAME PASS_FOR_OUTPUT FAIL_FOR_OUTPUT) + set(options REFERENCE_INKSCAPE) + set(oneValueArgs INPUT_FILENAME OUTPUT_FILENAME PASS_FOR_OUTPUT FAIL_FOR_OUTPUT REFERENCE_FILENAME) set(multiValueArgs PARAMETERS ENVIRONMENT EXPECTED_FILES TEST_SCRIPT) cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) set(CMAKE_CTEST_ENV "INKSCAPE_FONTCONFIG=${CMAKE_CURRENT_SOURCE_DIR}/../rendering_tests/fonts/isolated.conf;${CMAKE_CTEST_ENV}") @@ -91,6 +95,15 @@ function(add_cli_test_run fixture testname) set_tests_properties(${testname} PROPERTIES FAIL_REGULAR_EXPRESSION ${ARG_FAIL_FOR_OUTPUT}) endif() + if (${ARG_REFERENCE_INKSCAPE} AND DEFINED ARG_REFERENCE_FILENAME) + file(TO_NATIVE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/testcases/${ARG_REFERENCE_FILENAME}" REFERENCE_FILENAME) + set(ARG_PARAMETERS ${REFERENCE_FILENAME} "--export-png-use-dithering=false" + "--export-filename=${ARG_REFERENCE_FILENAME}.reference.png" "--export-make-paths") + add_test(NAME ${testname}_ref COMMAND inkscape ${ARG_PARAMETERS}) + set_tests_properties(${testname}_ref PROPERTIES + FIXTURES_SETUP ${fixture} + ENVIRONMENT "${CMAKE_CTEST_ENV}") + endif() endfunction(add_cli_test_run) # @@ -98,6 +111,7 @@ endfunction(add_cli_test_run) # function(add_cli_test_cmp fixture testname) # parse arguments + set(options REFERENCE_INKSCAPE) set(oneValueArgs OUTPUT_FILENAME OUTPUT_PAGE REFERENCE_FILENAME FUZZYREF_FILENAME FUZZ_PERCENTAGE RASTER_DPI PNG_FILENAME) set(multiValueArgs EXPECTED_FILES TEST_SCRIPT) @@ -118,7 +132,11 @@ function(add_cli_test_cmp fixture testname) # add test to check output files if(DEFINED ARG_REFERENCE_FILENAME OR DEFINED ARG_EXPECTED_FILES OR DEFINED ARG_TEST_SCRIPT) if(DEFINED ARG_REFERENCE_FILENAME) - file(TO_NATIVE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/testcases/${ARG_REFERENCE_FILENAME}" ARG_REFERENCE_FILENAME) + if (${ARG_REFERENCE_INKSCAPE}) + set(ARG_REFERENCE_FILENAME "${ARG_REFERENCE_FILENAME}.reference.png") + else() + file(TO_NATIVE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/testcases/${ARG_REFERENCE_FILENAME}" ARG_REFERENCE_FILENAME) + endif() endif() if(DEFINED ARG_EXPECTED_FILES) string(REPLACE ";" " " ARG_EXPECTED_FILES "${ARG_EXPECTED_FILES}") @@ -159,7 +177,8 @@ endfunction(add_cli_test_cmp) # Delete all the generated files # function(add_cli_test_cln fixture testname) - set(oneValueArgs INPUT_FILENAME OUTPUT_FILENAME PASS_FOR_OUTPUT FAIL_FOR_OUTPUT) + set(options REFERENCE_INKSCAPE) + set(oneValueArgs INPUT_FILENAME OUTPUT_FILENAME PASS_FOR_OUTPUT FAIL_FOR_OUTPUT REFERENCE_FILENAME) set(multiValueArgs PARAMETERS ENVIRONMENT EXPECTED_FILES TEST_SCRIPT) cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) @@ -170,6 +189,9 @@ function(add_cli_test_cln fixture testname) if(DEFINED ARG_EXPECTED_FILES) list(APPEND CREATED_FILES ${ARG_EXPECTED_FILES}) endif() + if (${ARG_REFERENCE_INKSCAPE} AND DEFINED ARG_REFERENCE_FILENAME) + list(APPEND CREATED_FILES ${ARG_REFERENCE_FILENAME}.reference.png) + endif() if(DEFINED CREATED_FILES) add_test("${testname}" rm -f ${CREATED_FILES}) set_tests_properties(${testname} PROPERTIES @@ -1039,6 +1061,16 @@ add_cli_test(actions-svginot-text-topath2 FUZZ_PERCENTAGE 1 ) +add_cli_test(actions-text-to-glyph + INPUT_FILENAME text_to_glyph.svg + PARAMETERS + --actions=select-all$text-convert-to-glyphs$select-clear$select-by-id:text_o_1$select-by-id:text_o_2$select-by-id:text_o_3$select-by-id:text_o_4$delete$ + OUTPUT_FILENAME text_to_glyph.png + REFERENCE_FILENAME text_to_glyph_expected.svgz REFERENCE_INKSCAPE + FUZZ_PERCENTAGE 8 +) + + # The export area type should be the last set value add_cli_test(actions-export-area-drawing-then-page INPUT_FILENAME areas.svg diff --git a/testfiles/cli_tests/testcases/text_to_glyph.svg b/testfiles/cli_tests/testcases/text_to_glyph.svg new file mode 100644 index 0000000000..33634010bb --- /dev/null +++ b/testfiles/cli_tests/testcases/text_to_glyph.svg @@ -0,0 +1,173 @@ + + + + + + + + fffiflafffiflaāa b eaלּaלּלּa aͨbͩcͤr̷͓͂r̷͓͂ a a + fffiflafffiflaāa b eaלּaלּלּa aͨbͩcͤr̷͓͂r̷͓͂ a a + fffiflafffiflaāa b eaלּaלּלּa aͨbͩcͤr̷͓͂r̷͓͂ a a + ab + + + diff --git a/testfiles/cli_tests/testcases/text_to_glyph_expected.svgz b/testfiles/cli_tests/testcases/text_to_glyph_expected.svgz new file mode 100644 index 0000000000000000000000000000000000000000..a87c21c07a217d5d2b6214d3cbe3b483e923da97 GIT binary patch literal 15010 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