diff --git a/src/display/CMakeLists.txt b/src/display/CMakeLists.txt index 30e13e3c3f2f21d73c8450cc234fc9ea490bcb18..b3e3fcddea39302dde504633ccc682b20fdb0025 100644 --- a/src/display/CMakeLists.txt +++ b/src/display/CMakeLists.txt @@ -61,6 +61,7 @@ set(display_SRC control/canvas-item-guideline.cpp control/canvas-item-quad.cpp control/canvas-item-rect.cpp + control/canvas-item-squiggle.cpp control/canvas-item-text.cpp control/canvas-page.cpp @@ -137,6 +138,7 @@ set(display_SRC control/canvas-item-ptr.h control/canvas-item-quad.h control/canvas-item-rect.h + control/canvas-item-squiggle.h control/canvas-item-text.h control/canvas-page.h ) diff --git a/src/display/control/canvas-item-squiggle.cpp b/src/display/control/canvas-item-squiggle.cpp new file mode 100644 index 0000000000000000000000000000000000000000..929a3bd42e76e109a24b5e9325156d3454e051a1 --- /dev/null +++ b/src/display/control/canvas-item-squiggle.cpp @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "canvas-item-squiggle.h" + +#include + +#include "display/cairo-utils.h" + +namespace Inkscape { + +namespace { + +// Helper Functions + +// Evaluate cubic Bezier at t in [0..1] +static inline Geom::Point eval_cubic(const Geom::Point &p0, const Geom::Point &c1, const Geom::Point &c2, const Geom::Point &p3, double t) +{ + double u = 1.0 - t; + + // Bernstein form + return p0 * (u*u*u) + c1 * (3.0 * u*u * t) + c2 * (3.0 * u * t*t) + p3 * (t*t*t); +} + +/* Build a sequence of cubic bezier segments that approximate a Centripetal Catmull-Rom spline through the input points. + Uses alpha = 0.5. + Converts each Catmull segment (P0,P1,P2,P3) into a cubic Bezier (B0,B1,B2,B3) via Hermite tangents -> Bezier control points. + + If knot distances are degenerate (very small) we use Uniform Catmull-Rom conversion for robustness. +*/ +void _build_catmull_beziers_from_points(const std::vector &pts, std::vector> &out_beziers) +{ + out_beziers.clear(); + if (pts.size() < 2) return; + + // If only two points, just make a straight cubic (control points on the line). + if (pts.size() == 2) { + Geom::Point p0 = pts[0]; + Geom::Point p1 = pts[1]; + Geom::Point c1 = p0 + (p1 - p0) * (1.0 / 3.0); + Geom::Point c2 = p0 + (p1 - p0) * (2.0 / 3.0); + out_beziers.push_back({ p0, c1, c2, p1 }); + return; + } + + constexpr double alpha = 0.5; // centripetal + constexpr double eps = 1e-8; // small epsilon to avoid /0 + + size_t n = pts.size(); + for (size_t i = 0; i < n - 1; ++i) { + // neighbors (clamped at ends) + Geom::Point P0 = (i == 0) ? pts[i] : pts[i - 1]; + Geom::Point P1 = pts[i]; + Geom::Point P2 = pts[i + 1]; + Geom::Point P3 = (i + 2 < n) ? pts[i + 2] : pts[i + 1]; + + // Compute chord lengths ^ alpha (centripetal knots) + double d01 = Geom::L2(P1 - P0); + double d12 = Geom::L2(P2 - P1); + double d23 = Geom::L2(P3 - P2); + + // Avoid exact zeros (which would make knot differences zero) + if (d01 < eps) d01 = eps; + if (d12 < eps) d12 = eps; + if (d23 < eps) d23 = eps; + + double t0 = 0.0; + double t1 = t0 + std::pow(d01, alpha); + double t2 = t1 + std::pow(d12, alpha); + double t3 = t2 + std::pow(d23, alpha); + + // If denominators are too small or knots collapsed, use Uniform Catmull-Rom formula. + bool small_denom = ( (t2 - t0) < eps ) || ( (t3 - t1) < eps ); + + Geom::Point B0 = P1; + Geom::Point B3 = P2; + Geom::Point B1, B2; + + if (small_denom) { + // Uniform Catmull-Rom + B1 = P1 + (P2 - P0) * (1.0 / 6.0); + B2 = P2 - (P3 - P1) * (1.0 / 6.0); + } else { + // Compute tangent (Hermite) vectors M1 and M2 for interval [t1,t2] + // Use stable coefficient formulas + double denom1 = (t1 - t0); + double denom2 = (t2 - t1); + double denom3 = (t3 - t2); + double denomA = (t2 - t0); // for c1,c2 + double denomB = (t3 - t1); // for d1,d2 + + // Precompute finite-difference velocities where safe + Geom::Point v10 = (denom1 > eps) ? ((P1 - P0) / denom1) : Geom::Point(0.0, 0.0); + Geom::Point v21 = (denom2 > eps) ? ((P2 - P1) / denom2) : Geom::Point(0.0, 0.0); + Geom::Point v32 = (denom3 > eps) ? ((P3 - P2) / denom3) : Geom::Point(0.0, 0.0); + + // coefficients + double c1 = (denomA > eps) ? ((t2 - t1) / denomA) : 0.0; + double c2 = (denomA > eps) ? ((t1 - t0) / denomA) : 0.0; + + double d1 = (denomB > eps) ? ((t3 - t2) / denomB) : 0.0; + double d2 = (denomB > eps) ? ((t2 - t1) / denomB) : 0.0; + + // M1 and M2 scaled to the interval [t1,t2] (see formulas in description) + Geom::Point M1 = (v10 * c1 + v21 * c2) * (t2 - t1); + Geom::Point M2 = (v21 * d1 + v32 * d2) * (t2 - t1); + + // Convert Hermite (P1,P2,M1,M2) to Bezier control points + B1 = P1 + M1 * (1.0 / 3.0); + B2 = P2 - M2 * (1.0 / 3.0); + } + + out_beziers.push_back({ B0, B1, B2, B3 }); + } +} + +// Flatten a list of cubic beziers to dense polyline (sampled points), also produce cumulated lengths +void _flatten_beziers(const std::vector> &beziers, std::vector &out_poly, std::vector &out_cumlen, double dt) +{ + out_poly.clear(); + out_cumlen.clear(); + if (beziers.empty()) return; + + // sample each bezier + out_poly.push_back(beziers[0][0]); + out_cumlen.push_back(0.0); + + for (const auto &bz : beziers) { + // sample t from dt..1.0 inclusive (first t=0 already added) + for (double t = dt; t <= 1.0 + 1e-12; t += dt) { + double tt = t; + if (tt > 1.0) tt = 1.0; + Geom::Point pt = eval_cubic(bz[0], bz[1], bz[2], bz[3], tt); + if (pt != out_poly.back()) { + double seg = Geom::L2(pt - out_poly.back()); + out_poly.push_back(pt); + out_cumlen.push_back(out_cumlen.back() + seg); + } + } + } +} + +/* sample on the flattened polyline by arc-length s (0..total_len) + produce interpolated point and tangent (approx from neighboring samples) */ +void _sample_poly_by_arc(const std::vector &poly, const std::vector &cumlen, double s, Geom::Point &out_pt, Geom::Point &out_tangent) +{ + if (poly.empty()) { + out_pt = Geom::Point(0,0); + out_tangent = Geom::Point(1,0); + return; + } + + double total = cumlen.back(); + if (s <= 0.0) { + out_pt = poly.front(); + out_tangent = (poly.size() > 1) ? (poly[1] - poly[0]) : Geom::Point(1,0); + return; + } + if (s >= total) { + out_pt = poly.back(); + out_tangent = (poly.size() > 1) ? (poly.back() - poly[poly.size()-2]) : Geom::Point(1,0); + return; + } + + // binary search for segment + auto it = std::lower_bound(cumlen.begin(), cumlen.end(), s); + size_t idx = std::distance(cumlen.begin(), it); + if (idx == 0) idx = 1; + + // interpolate between idx-1 and idx + double s0 = cumlen[idx-1]; + double s1 = cumlen[idx]; + double frac = (s1 - s0) > 1e-12 ? (s - s0) / (s1 - s0) : 0.0; + Geom::Point p0 = poly[idx-1]; + Geom::Point p1 = poly[idx]; + out_pt = p0 + (p1 - p0) * frac; + + // tangent approx by neighbor + Geom::Point ahead, behind; + if (idx + 1 < poly.size()) ahead = poly[idx+1]; + else ahead = p1; + if (idx >= 2) behind = poly[idx-2]; + else behind = p0; + + out_tangent = (ahead - behind); +} + +} + +CanvasItemSquiggle::CanvasItemSquiggle(CanvasItemGroup *group, Geom::Point const &start, Geom::Point const &end, uint32_t color) + : CanvasItem(group) + , _start(start) + , _end(end) + , _color(color) +{ + _name = "CanvasItemSquiggle"; + _pickable = false; + + // defaults + _amplitude = 3.5; + _wavelength = 8.0; + _sample_dt = 0.02; // sampling step for flattening beziers + + request_update(); +} + +CanvasItemSquiggle::CanvasItemSquiggle(CanvasItemGroup *group, std::vector const &points, uint32_t color) + : CanvasItem(group) + , _points(points) + , _color(color) +{ + _name = "CanvasItemSquiggle"; + _pickable = false; + + // defaults + _amplitude = 3.5; + _wavelength = 8.0; + _sample_dt = 0.02; + + request_update(); +} + +void CanvasItemSquiggle::set_points(Geom::Point start, Geom::Point end) +{ + _points.clear(); + if (_start != start || _end != end) { + _start = start; + _end = end; + request_update(); + } +} + +void CanvasItemSquiggle::set_points(std::vector const &points) +{ + if (_points != points) { + _points = points; + request_update(); + } +} + +void CanvasItemSquiggle::set_squiggle_params(double amplitude, double wavelength, double sample_dt) +{ + _amplitude = amplitude; + _wavelength = wavelength; + _sample_dt = sample_dt > 0.0 ? sample_dt : 0.02; + request_update(); +} + +void CanvasItemSquiggle::set_color(uint32_t color) +{ + if (_color != color) { + _color = color; + request_redraw(); + } +} + +void CanvasItemSquiggle::_rebuild_squiggle() +{ + // Transform start/end/points from document to canvas units + Geom::Affine aff = affine(); + + // collect base points in canvas coords + std::vector base_pts; + if (!_points.empty()) { + base_pts.reserve(_points.size()); + for (auto const &p : _points) base_pts.push_back(p * aff); + } else { + Geom::Point s = _start * aff; + Geom::Point e = _end * aff; + base_pts.push_back(s); + base_pts.push_back(e); + } + + _squiggle_path.clear(); + + // minimum length threshold + constexpr double min_canvas_len = 4.0; + // compute straight-line length as a quick check + double approx_len = 0.0; + for (size_t i = 1; i < base_pts.size(); ++i) approx_len += Geom::L2(base_pts[i] - base_pts[i-1]); + if (approx_len < min_canvas_len) { + return; + } + + // 1) Build Catmull-Rom -> cubic beziers + std::vector> beziers; + _build_catmull_beziers_from_points(base_pts, beziers); + + // 2) Flatten to a dense polyline and arc-length table + std::vector poly; + std::vector cumlen; + _flatten_beziers(beziers, poly, cumlen, _sample_dt); + + if (poly.size() < 2) { + return; + } + + double total_len = cumlen.back(); + + // squiggle params (already in canvas/screen units) + double amplitude = _amplitude; // in canvas units + double wavelength = _wavelength; // in canvas units + int n = std::max(1, int(total_len / wavelength)); + double step = total_len / n; + + // Build squiggle by sampling along baseline and offsetting perpendicular + Geom::Path path; + // start at baseline first point (no offset) + Geom::Point first_base = poly.front(); + path.start(first_base); + + // previous baseline point (for computing ctrl midpoints) + Geom::Point prev_base = first_base; + // previous offset point — for the first segment it equals first_base (no offset) + Geom::Point prev_offset = first_base; + + for (int i = 1; i <= n; ++i) { + double s = i * step; + Geom::Point base_pt, tangent; + _sample_poly_by_arc(poly, cumlen, s, base_pt, tangent); + + // compute perpendicular; normalize tangent first + double tlen = Geom::L2(tangent); + Geom::Point dir = (tlen > 1e-8) ? (tangent / tlen) : Geom::Point(1.0, 0.0); + Geom::Point perp(-dir[1], dir[0]); + + double sign = (i % 2 == 0) ? 1.0 : -1.0; + Geom::Point offset_pt = base_pt + perp * (amplitude * sign); + + // control point placed at mid baseline between prev_base and base_pt and shifted by same sign*amplitude + Geom::Point baseline_mid = prev_base + (base_pt - prev_base) * 0.5; + Geom::Point ctrl = baseline_mid + perp * (amplitude * sign); + + // create a cubic using ctrl repeated as symmetric control points (like you had earlier) + path.appendNew(ctrl, ctrl, offset_pt); + + // advance + prev_base = base_pt; + prev_offset = offset_pt; + } + + // store the result path (in canvas coords) + _squiggle_path.push_back(path); +} + +void CanvasItemSquiggle::_update(bool) +{ + request_redraw(); + + _rebuild_squiggle(); + + // Set bounds based on either _points or _start/_end + Geom::Rect bounds_doc; + if (!_points.empty()) { + bounds_doc = Geom::Rect(_points.front(), _points.back()); + for (auto const &p : _points) bounds_doc.unionWith(Geom::Rect(p, p)); + } else { + bounds_doc = Geom::Rect(_start, _end); + } + + bounds_doc.expandBy(5.0); // Expand by 5 canvas units, convert to doc units + _bounds = bounds_doc; + + *_bounds *= affine(); + + request_redraw(); +} + +void CanvasItemSquiggle::_render(CanvasItemBuffer &buf) const +{ + if (_squiggle_path.empty()) { + return; + } + + buf.cr->save(); + + buf.cr->set_tolerance(0.5); + buf.cr->begin_new_path(); + + // Draw in screen coordinates but no affine transformation cause it is already in canvas coordinates + feed_pathvector_to_cairo(buf.cr->cobj(), _squiggle_path, Geom::Affine(), buf.rect, true, 0); + + ink_cairo_set_source_color(buf.cr, Colors::Color(_color)); + buf.cr->set_line_width(1.5); + buf.cr->stroke(); + + buf.cr->restore(); +} + +} // namespace Inkscape \ No newline at end of file diff --git a/src/display/control/canvas-item-squiggle.h b/src/display/control/canvas-item-squiggle.h new file mode 100644 index 0000000000000000000000000000000000000000..eabc21ec961f7f6488116545b06daab22fc1ee87 --- /dev/null +++ b/src/display/control/canvas-item-squiggle.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifndef SEEN_CANVAS_ITEM_SQUIGGLE_H +#define SEEN_CANVAS_ITEM_SQUIGGLE_H + +/** + * A class to represent squiggles + */ + +#include +#include <2geom/pathvector.h> +#include <2geom/point.h> + +#include "canvas-item.h" + +namespace Inkscape { + +class CanvasItemSquiggle : public CanvasItem +{ +public: + CanvasItemSquiggle(CanvasItemGroup *group, Geom::Point const &start, Geom::Point const &end, uint32_t color = 0xff0000ff); + CanvasItemSquiggle(CanvasItemGroup *group, std::vector const &points, uint32_t color = 0xff0000ff); + + // Properties + void set_points(Geom::Point start, Geom::Point end); + void set_points(std::vector const &points); + void set_squiggle_params(double amplitude = 3.5, double wavelength = 8.0, double sample_dt = 0.02); + void set_color(uint32_t color); + +protected: + ~CanvasItemSquiggle() override = default; + + void _update(bool propagate) override; + void _render(CanvasItemBuffer &buf) const override; + +private: + // Geometry + Geom::Point _start; + Geom::Point _end; + + std::vector _points; + + uint32_t _color; + double _amplitude; // Amplitude of the squiggle in canvas units + double _wavelength; // Wavelength of the squiggle in canvas units + double _sample_dt; // Sampling step for drawing the squiggle + + Geom::PathVector _squiggle_path; + + // Rebuilding + void _rebuild_squiggle(); +}; + +} // namespace Inkscape + +#endif // SEEN_CANVAS_ITEM_SQUIGGLE_H \ No newline at end of file diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 5c9d9c3544d95f02f4bddaffbdf39d89c50959c5..20b73a8efba1209201467b3c6bae56bee1ba4cb3 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -24,6 +24,7 @@ set(ui_SRC tool-factory.cpp util.cpp modifiers.cpp + on-canvas-spellcheck.cpp cache/svg_preview_cache.cpp @@ -336,6 +337,7 @@ set(ui_SRC tool-factory.h util.h modifiers.h + on-canvas-spellcheck.h cache/svg_preview_cache.h diff --git a/src/ui/contextmenu.cpp b/src/ui/contextmenu.cpp index 5f5fd7012069cf76bf206d655b264f53566e8e43..7860c0a341955303fc27959b3d99c6203493228b 100644 --- a/src/ui/contextmenu.cpp +++ b/src/ui/contextmenu.cpp @@ -42,6 +42,9 @@ #include "selection.h" #include "ui/desktop/menu-set-tooltips-shift-icons.h" #include "ui/util.h" +#include "ui/widget/desktop-widget.h" +#include "ui/tools/text-tool.h" +#include "ui/on-canvas-spellcheck.h" static void AppendItemFromAction(Glib::RefPtr const &gmenu, @@ -153,6 +156,62 @@ ContextMenu::ContextMenu(SPDesktop *desktop, SPObject *object, std::vector(item) && prefs->getBool("/dialogs/spellcheck/live", false)) { + + auto text_tool = dynamic_cast(desktop->getTool()); + + if(text_tool) + { + auto sel_start = text_tool->text_sel_start; + auto sel_end = text_tool->text_sel_end; + + auto spellcheck = text_tool->getSpellcheck(); + + if(spellcheck && spellcheck->isMisspelled(item, sel_start, sel_end)) + { + auto corrections = spellcheck->getCorrections(item, sel_start, sel_end); + + if (!corrections.empty()) { + spellcheck_enabled = true; + gmenu_section = Gio::Menu::create(); + int count = 0; + for (auto const &correction : corrections) { + count++; + if(count > 5) break; + auto label = Glib::ustring::compose(_("%1"), correction); + auto action_name = Glib::ustring::compose("spellcheck-replace-%1", correction); + action_group->add_action(action_name, sigc::bind(sigc::mem_fun(*spellcheck, &Inkscape::UI::OnCanvasSpellCheck::replaceWord), item, sel_start, sel_end, correction)); + AppendItemFromAction(gmenu_section, "ctx." + action_name, label); + } + gmenu->append_section(gmenu_section); + } + + gmenu_section = Gio::Menu::create(); + action_group->add_action("spellcheck-ignore-once", sigc::bind(sigc::mem_fun(*spellcheck, &Inkscape::UI::OnCanvasSpellCheck::ignoreOnce), item, sel_start, sel_end)); + AppendItemFromAction(gmenu_section, "ctx.spellcheck-ignore-once", _("Ignore Once"), "edit-ignore-once"); + + action_group->add_action("spellcheck-ignore", sigc::bind(sigc::mem_fun(*spellcheck, &Inkscape::UI::OnCanvasSpellCheck::ignore), item, sel_start, sel_end)); + AppendItemFromAction(gmenu_section, "ctx.spellcheck-ignore", _("Ignore"), "edit-ignore"); + + action_group->add_action("spellcheck-add-to-dictionary", sigc::bind(sigc::mem_fun(*spellcheck, &Inkscape::UI::OnCanvasSpellCheck::addToDictionary), item, sel_start, sel_end)); + AppendItemFromAction(gmenu_section, "ctx.spellcheck-add-to-dictionary", _("Add to Dictionary"), "edit-add-to-dictionary"); + + gmenu->append_section(gmenu_section); + + gmenu->append_section(create_clipboard_actions()); + + gmenu_section = Gio::Menu::create(); + AppendItemFromAction(gmenu_section, "app.duplicate", _("Duplic_ate"), "edit-duplicate"); + AppendItemFromAction(gmenu_section, "app.clone", _("_Clone"), "edit-clone"); + AppendItemFromAction(gmenu_section, "app.delete-selection", _("_Delete"), "edit-delete"); + gmenu->append_section(gmenu_section); + } + } + } + bool has_hidden_below_cursor = false; bool has_locked_below_cursor = false; for (auto item : items_under_cursor) { @@ -190,7 +249,7 @@ ContextMenu::ContextMenu(SPDesktop *desktop, SPObject *object, std::vectorappend_section(gmenu_section); - } else if (!layer || desktop->getSelection()->includes(layer)) { + } else if (!spellcheck_enabled && (!layer || desktop->getSelection()->includes(layer))) { // "item" is the object that was under the mouse when right-clicked. It determines what is shown // in the menu thus it makes the most sense that it is either selected or part of the current // selection. @@ -338,7 +397,7 @@ ContextMenu::ContextMenu(SPDesktop *desktop, SPObject *object, std::vectorappend_section(gmenu_section); - } else { + } else if(!spellcheck_enabled){ // Layers: Only used in "Layers and Objects" dialog. gmenu_section = Gio::Menu::create(); @@ -378,7 +437,7 @@ ContextMenu::ContextMenu(SPDesktop *desktop, SPObject *object, std::vectorgetInt("/theme/shiftIcons", true); set_tooltips_and_shift_icons(*this, shift_icons); // Set the style and icon theme of the new menu based on the desktop diff --git a/src/ui/dialog/inkscape-preferences.cpp b/src/ui/dialog/inkscape-preferences.cpp index 438712c27c036483724394fa072d0d3eaceae9df..31b73410dea3dfd501d6362aab029ee8373cb981 100644 --- a/src/ui/dialog/inkscape-preferences.cpp +++ b/src/ui/dialog/inkscape-preferences.cpp @@ -82,6 +82,9 @@ #include "util/trim.h" #include "widgets/spw-utilities.h" +#include "ui/libspelling-wrapper.h" +#include "ui/tools/text-tool.h" + namespace Inkscape::UI::Dialog { using Inkscape::UI::Widget::DialogPage; @@ -3747,14 +3750,73 @@ void InkscapePreferences::onKBListKeyboardShortcuts() } } +void InkscapePreferences::spellcheckPreferencesChanged() +{ +#if WITH_LIBSPELLING + SPDesktop *desktop = SP_ACTIVE_DESKTOP; + + auto text_tool = dynamic_cast(desktop->getTool()); + + if (!text_tool) { + return; + } + + auto spellcheck = text_tool->getSpellcheck(); + if (!spellcheck) { + return; + } + + spellcheck->reinitialize(); +#endif +} + void InkscapePreferences::initPageSpellcheck() { #if WITH_LIBSPELLING _spell_ignorenumbers.init(_("Ignore words with digits"), "/dialogs/spellcheck/ignorenumbers", true); _page_spellcheck.add_line(false, "", _spell_ignorenumbers, "", _("Ignore words containing digits, such as \"R2D2\""), true); + _spell_ignorenumbers.signal_toggled().connect(sigc::mem_fun(*this, &InkscapePreferences::spellcheckPreferencesChanged)); _spell_ignoreallcaps.init(_("Ignore words in ALL CAPITALS"), "/dialogs/spellcheck/ignoreallcaps", false); _page_spellcheck.add_line(false, "", _spell_ignoreallcaps, "", _("Ignore words in all capitals, such as \"IUPAC\""), true); + _spell_ignoreallcaps.signal_toggled().connect(sigc::mem_fun(*this, &InkscapePreferences::spellcheckPreferencesChanged)); + + _spell_live.init(_("Turn On live spellchecking"), "/dialogs/spellcheck/live", true); + _page_spellcheck.add_line(false, "", _spell_live, "", _("Enable live spellchecking while typing"), true); + _spell_live.signal_toggled().connect(sigc::mem_fun(*this, &InkscapePreferences::spellcheckPreferencesChanged)); + + + std::vector lang_labels; + std::vector lang_codes; + auto provider = spelling_provider_get_default(); + Inkscape::UI::list_language_names_and_codes(provider, [&](auto name, auto code) { + lang_labels.push_back(name); + lang_codes.push_back(code); + return false; + }); + + // Get saved language code from preferences + auto prefs = Inkscape::Preferences::get(); + Glib::ustring saved_code = prefs->getString("/dialogs/spellcheck/live_lang"); + + // Initialize PrefCombo + _spell_live_lang.init("/dialogs/spellcheck/live_lang", + std::span(lang_labels.data(), lang_labels.size()), + std::span(lang_codes.data(), lang_codes.size()), + saved_code); + + // Save selection to preferences when changed + _spell_live_lang.signal_changed().connect([prefs, lang_labels, lang_codes, this]() { + int idx = _spell_live_lang.get_selected(); + if (idx >= 0 && idx < (int)lang_codes.size()) { + prefs->setString("/dialogs/spellcheck/live_lang", lang_codes[idx]); + this->spellcheckPreferencesChanged(); + } + }); + + // Add to preferences page + _page_spellcheck.add_line(false, _("Live Spellcheck language:"), _spell_live_lang, "", + _("Select the language used for live spellchecking."), true); AddPage(_page_spellcheck, _("Spellcheck"), PREFS_PAGE_SPELLCHECK); #endif diff --git a/src/ui/dialog/inkscape-preferences.h b/src/ui/dialog/inkscape-preferences.h index 1d47aef6df573706dcb1c16d509737d8bfc4894e..c8dff03d3b1d30ebb6f4a1270958b32732f43eb9 100644 --- a/src/ui/dialog/inkscape-preferences.h +++ b/src/ui/dialog/inkscape-preferences.h @@ -472,6 +472,8 @@ protected: UI::Widget::PrefCombo _spell_language3; UI::Widget::PrefCheckButton _spell_ignorenumbers; UI::Widget::PrefCheckButton _spell_ignoreallcaps; + UI::Widget::PrefCheckButton _spell_live; + UI::Widget::PrefCombo _spell_live_lang; // Bitmaps UI::Widget::PrefCombo _misc_overs_bitmap; @@ -701,6 +703,7 @@ private: void resetIconsColorsWrapper(); void get_highlight_colors(guint32 &colorsetbase, guint32 &colorsetsuccess, guint32 &colorsetwarning, guint32 &colorseterror); + void spellcheckPreferencesChanged(); std::map dark_themes; bool _init; diff --git a/src/ui/dialog/spellcheck.cpp b/src/ui/dialog/spellcheck.cpp index b83de6fb58cd2fb1577cd9ce289fe94de2e604ea..e1ffaaae2ac8b039fbf3269a51116d9149a1c2db 100644 --- a/src/ui/dialog/spellcheck.cpp +++ b/src/ui/dialog/spellcheck.cpp @@ -30,6 +30,7 @@ #include "selection-chemistry.h" #include "text-editing.h" #include "display/control/canvas-item-rect.h" +#include "display/control/canvas-item-squiggle.h" #include "object/sp-defs.h" #include "object/sp-flowtext.h" #include "object/sp-object.h" @@ -407,10 +408,16 @@ bool SpellCheck::nextWord() area.expandBy(std::max(0.05 * mindim, 1.0)); // Create canvas item rect with red stroke. (TODO: a quad could allow non-axis aligned rects.) - auto rect = new Inkscape::CanvasItemRect(desktop->getCanvasSketch(), area); - rect->set_stroke(0xff0000ff); - rect->set_visible(true); - _rects.emplace_back(rect); + // auto rect = new Inkscape::CanvasItemRect(desktop->getCanvasSketch(), area); + // rect->set_stroke(0xff0000ff); + // rect->set_visible(true); + // _rects.emplace_back(rect); + + auto squiggle = new Inkscape::CanvasItemSquiggle(desktop->getCanvasSketch(), area.corner(3), area.corner(2)); + squiggle->set_stroke(0xff0000ff); + squiggle->set_visible(true); + _rects.emplace_back(squiggle); + // scroll to make it all visible Geom::Point const center = desktop->current_center(); diff --git a/src/ui/dialog/spellcheck.h b/src/ui/dialog/spellcheck.h index 55009083004a68515c8277506592214c479f3cce..d992bcf32d313849b11732578c9c3f03a90149e0 100644 --- a/src/ui/dialog/spellcheck.h +++ b/src/ui/dialog/spellcheck.h @@ -41,6 +41,7 @@ class SPCanvasItem; namespace Inkscape { class Preferences; class CanvasItemRect; +class CanvasItemSquiggle; } // namespace Inkscape { namespace Inkscape::UI::Dialog { @@ -166,7 +167,7 @@ private: /** * list of canvasitems (currently just rects) that mark misspelled things on canvas */ - std::vector> _rects; + std::vector> _rects; /** * list of text objects we have already checked in this session diff --git a/src/ui/on-canvas-spellcheck.cpp b/src/ui/on-canvas-spellcheck.cpp new file mode 100644 index 0000000000000000000000000000000000000000..e4d29d77065e9cae98ed9ce76d069e78300ae7f6 --- /dev/null +++ b/src/ui/on-canvas-spellcheck.cpp @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include "on-canvas-spellcheck.h" +#include "inkscape.h" +#include "document.h" +#include "desktop.h" +#include "layer-manager.h" +#include "ui/libspelling-wrapper.h" +#include "ui/dialog/inkscape-preferences.h" +#include "object/sp-object.h" +#include "object/sp-root.h" +#include "object/sp-defs.h" +#include "object/sp-text.h" +#include "object/sp-flowtext.h" +#include "display/control/canvas-item-squiggle.h" +#include "document-undo.h" +#include "ui/icon-names.h" + +namespace Inkscape::UI { + +namespace { + +const char *IGNORED_WORDS_FILE = "inkscape/ignored-words.txt"; +const char *ACCEPTED_WORDS_FILE = "inkscape/accepted-words.txt"; + +constexpr uint32_t SQUIGGLE_COLOR_RED = 0xff0000ff; // Color for squiggles + +std::string get_user_ignore_path() +{ + return Glib::build_filename(Glib::get_user_config_dir(), IGNORED_WORDS_FILE); +} + +std::string get_user_dict_path() +{ + return Glib::build_filename(Glib::get_user_config_dir(), ACCEPTED_WORDS_FILE); +} + +void ensure_file_exists(const std::string& path) +{ + std::ofstream f(path, std::ios::app); +} + +static inline double safe_pow(double x, double p) { + return std::pow(x < 0.0 ? 0.0 : x, p); +} + +} + + +OnCanvasSpellCheck::OnCanvasSpellCheck(SPDesktop *desktop) + : _desktop(desktop) +{ + // Get the current document root + auto doc = desktop->getDocument(); + if (!doc) return; + _root = static_cast(doc->getRoot()); + + initialize(); +} + +void OnCanvasSpellCheck::initialize() +{ + // Get the default spelling provider + _provider = spelling_provider_get_default(); + + // Choose a language (for example, the first available) + auto prefs = Inkscape::Preferences::get(); + _lang_code = prefs->getString("/dialogs/spellcheck/live_lang", "en_US"); + + // Create the checker + _checker = GObjectPtr(spelling_checker_new(_provider, _lang_code.c_str())); + + // Open Ignored Words File and add ignored words to checker + ensure_file_exists(get_user_ignore_path()); + std::ifstream ignore_file(get_user_ignore_path()); + if (ignore_file.is_open()) { + std::string word; + while (std::getline(ignore_file, word)) { + if (!word.empty()) { + _ignored_words.push_back(word); + spelling_checker_ignore_word(_checker.get(), word.c_str()); + } + } + ignore_file.close(); + } + + // Open Accepted Words File and add accepted words to checker + ensure_file_exists(get_user_dict_path()); + std::ifstream dict_file(get_user_dict_path()); + if (dict_file.is_open()) { + std::string word; + while (std::getline(dict_file, word)) { + if (!word.empty()) { + _added_words.push_back(word); + spelling_checker_add_word(_checker.get(), word.c_str()); + } + } + dict_file.close(); + } + + // If live spellcheck is enabled, start scanning the document + if(prefs->getBool("/dialogs/spellcheck/live", false)) { + scanDocument(); + } +} + +void OnCanvasSpellCheck::reinitialize() +{ + // Clear previous data + _ignored_words.clear(); + _added_words.clear(); + _tracked_items.clear(); + _checker.reset(); + + // Re-initialize + initialize(); +} + +void OnCanvasSpellCheck::allTextItems(SPObject *root, std::vector &list, bool hidden, bool locked) +{ + if (is(root) || !std::strcmp(root->getRepr()->name(), "svg:metadata")) { + return; // we're not interested in items in defs and metadata + } + + if (_desktop) { + for (auto &child: root->children) { + if (auto item = cast(&child)) { + if (!child.cloned && !_desktop->layerManager().isLayer(item)) { + if ((hidden || !_desktop->itemIsHidden(item)) && (locked || !item->isLocked())) { + if (is(item) || is(item)) { + list.push_back(item); + } + } + } + } + allTextItems(&child, list, hidden, locked); + } + } +} + +void OnCanvasSpellCheck::scanDocument() +{ + // Clear any previously tracked items + _tracked_items.clear(); + + std::vector items; + // Uses similar logic as SpellCheck::allTextItems to collect all SPText/SPFlowText + allTextItems(_root, items, false, true); + // For each item: + for (auto item : items) { + checkTextItem(item); + } +} + +void OnCanvasSpellCheck::checkTextItem(SPItem* item) +{ + //Delete Item from TrackedTextItems if it exists + auto it_item = std::find_if(_tracked_items.begin(), _tracked_items.end(), + [item](const TrackedTextItem& tracked) { return tracked.item == item; }); + if (it_item != _tracked_items.end()) { + // Disconnect connections for the item + it_item->modified_connection.disconnect(); + it_item->release_connection.disconnect(); + _tracked_items.erase(it_item); + } + // Create a new tracked item + TrackedTextItem tracked_item; + tracked_item.item = item; + + // Scaning the item for misspelled words + std::vector misspelled_words; + auto layout = te_get_layout(item); + auto it = layout->begin(); + while (it != layout->end()) { + if (!layout->isStartOfWord(it)) { + it.nextStartOfWord(); + if (it == layout->end()) break; + } + auto begin = it; + auto end = it; + end.nextEndOfWord(); + + // Try to link with the next word if separated by an apostrophe + { + SPObject *char_item = nullptr; + Glib::ustring::iterator text_iter; + layout->getSourceOfCharacter(end, &char_item, &text_iter); + if (char_item && is(char_item)) { + int ch = *text_iter; + if (ch == '\'' || ch == 0x2019) { + auto tempEnd = end; + tempEnd.nextCharacter(); + layout->getSourceOfCharacter(tempEnd, &char_item, &text_iter); + if (char_item && is(char_item)) { + int nextChar = *text_iter; + if (g_ascii_isalpha(nextChar)) + end.nextEndOfWord(); + } + } + } + } + + Glib::ustring _word = sp_te_get_string_multiline(item, begin, end); + + // Check if word is empty + if(_word.empty()) + { + it = end; + continue; + } + + auto prefs = Inkscape::Preferences::get(); + + // Skip words containing digits + if(prefs->getInt("/dialogs/spellcheck/ignorenumbers") != 0 && + std::any_of(_word.begin(), _word.end(), [](gchar c) { return g_unichar_isdigit(c); })) { + it = end; + continue; + } + + // Skip ALL-CAPS words + if(prefs->getInt("/dialogs/spellcheck/ignoreallcaps") != 0 && + std::all_of(_word.begin(), _word.end(), [](gchar c) { return g_unichar_isupper(c); })) { + it = end; + continue; + } + + if (!_word.empty() && !spelling_checker_check_word(_checker.get(), _word.c_str(), _word.length())) { + // auto squiggle = createSquiggle(item, _word, begin, end); + // _misspelled_words.push_back({item, _word, begin, end, std::move(squiggle)}); + misspelled_words.push_back(MisspelledWord{_word, begin, end, {}}); + createSquiggle(misspelled_words.back(), item, layout); + } + it = end; + } + + // Add the misspelled words to the tracked item + tracked_item.misspelled_words = std::move(misspelled_words); + + // Add signals for the item + tracked_item.modified_connection = item->connectModified( + [this, item] (auto, auto) { + auto it = std::find_if(_tracked_items.begin(), _tracked_items.end(), + [item](const TrackedTextItem& tracked) { return tracked.item == item; }); + if (it != _tracked_items.end()) { + onObjModified(*it); + } + } + ); + tracked_item.release_connection = item->connectRelease( + [this, item] (auto) { + auto it = std::find_if(_tracked_items.begin(), _tracked_items.end(), + [item](const TrackedTextItem& tracked) { return tracked.item == item; }); + if (it != _tracked_items.end()) { + onObjReleased(*it); + } + } + ); + + // Add the tracked item to the list of tracked items + _tracked_items.push_back(std::move(tracked_item)); + +} + +void OnCanvasSpellCheck::createSquiggle(MisspelledWord& misspelled, SPItem* item, const Text::Layout* layout) +{ + if (!layout) { + return; // No layout available + } + // Get the selection shape (bounding box) for the word + auto points = layout->createSelectionShape(misspelled.begin, misspelled.end, item->i2dt_affine()); + if (points.size() < 4) { + return; // Not enough points to draw a squiggle + } + + // Initially get the bottom left and bottom right points + std::vector squiggle_points_temp; + for(int i=0; i squiggle_points_temp2; + squiggle_points_temp2.push_back(squiggle_points_temp[0]); + + for(int i=1; i( + new Inkscape::CanvasItemSquiggle(_desktop->getCanvasSketch(), squiggle_points_temp2, SQUIGGLE_COLOR_RED) + )); + misspelled.squiggle.back()->set_squiggle_params(3.0, 8.0, 0.10); // Amplitude, Wavelength, Sampling + misspelled.squiggle.back()->set_visible(true); +} + +void OnCanvasSpellCheck::onObjModified(TrackedTextItem &tracked_item) +{ + // When an object is modified, we need to re-check it for misspelled words + // This will remove the old squiggles and create new ones if necessary + checkTextItem(tracked_item.item); +} + +void OnCanvasSpellCheck::onObjReleased(TrackedTextItem &tracked_item) +{ + // When an object is released, we can remove it from the tracked items + auto it = std::find_if(_tracked_items.begin(), _tracked_items.end(), + [&tracked_item](const TrackedTextItem& tracked) { return tracked.item == tracked_item.item; }); + if (it != _tracked_items.end()) { + // Disconnect connections for the item + it->modified_connection.disconnect(); + it->release_connection.disconnect(); + _tracked_items.erase(it); + } +} + +void OnCanvasSpellCheck::addTrackedItem(SPItem* item) +{ + checkTextItem(item); +} + +bool OnCanvasSpellCheck::isMisspelled(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end) const +{ + // Check if the word is misspelled in the tracked items + auto it = std::find_if(_tracked_items.begin(), _tracked_items.end(), + [item](const TrackedTextItem& tracked) { return tracked.item == item; }); + if (it != _tracked_items.end()) { + for (const auto& misspelled : it->misspelled_words) { + if (misspelled.begin == begin && misspelled.end == end) { + return true; // Found a match + } + } + } + return false; // Not found +} + +std::vector OnCanvasSpellCheck::getCorrections(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end) const +{ + if(!isMisspelled(item, begin, end)) + { + return {}; // No corrections if the word is not misspelled + } + + auto word = sp_te_get_string_multiline(item, begin, end); + + auto corrections = list_corrections(_checker.get(), word.c_str()); + + return corrections; + +} + +void OnCanvasSpellCheck::replaceWord(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end, const Glib::ustring &replacement) +{ + // Replace the word in the text item + sp_te_replace(item, begin, end, replacement.c_str()); + + // Store the undo action + DocumentUndo::done(_desktop->getDocument(), _("Fix spelling (live)"), INKSCAPE_ICON("draw-text")); + + // After replacing, we need to re-check the item for misspelled words + checkTextItem(item); +} + +void OnCanvasSpellCheck::ignoreOnce(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end) +{ + // Ignore the word once + auto it = std::find_if(_tracked_items.begin(), _tracked_items.end(), + [item](const TrackedTextItem& tracked) { return tracked.item == item; }); + if (it != _tracked_items.end()) { + + auto mispelled_it = std::find_if(it->misspelled_words.begin(), it->misspelled_words.end(), + [begin, end](const MisspelledWord& misspelled) { + return misspelled.begin == begin && misspelled.end == end; + }); + if (mispelled_it != it->misspelled_words.end()) { + // Remove the squiggle + if (mispelled_it->squiggle.size() > 0) { + for(auto &squiggle_part: mispelled_it->squiggle) { + squiggle_part->set_visible(false); + } + mispelled_it->squiggle.clear(); + } + // Remove the misspelled word from the list + it->misspelled_words.erase(mispelled_it); + } + } +} + +void OnCanvasSpellCheck::ignore(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end) +{ + // Ignore the word permanently + auto word = sp_te_get_string_multiline(item, begin, end); + if (std::find(_ignored_words.begin(), _ignored_words.end(), word) == _ignored_words.end()) { + _ignored_words.push_back(word); + spelling_checker_ignore_word(_checker.get(), word.c_str()); + + // Save the ignored word to the file + std::ofstream ignore_file(get_user_ignore_path(), std::ios::app); + if (ignore_file.is_open()) { + ignore_file << word << std::endl; + ignore_file.close(); + } + } + + scanDocument(); // Re-scan the document to update the squiggles +} + +void OnCanvasSpellCheck::addToDictionary(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end) +{ + // Add the word to the dictionary + auto word = sp_te_get_string_multiline(item, begin, end); + if (std::find(_added_words.begin(), _added_words.end(), word) == _added_words.end()) { + _added_words.push_back(word); + spelling_checker_add_word(_checker.get(), word.c_str()); + + // Save the accepted word to the file + std::ofstream dict_file(get_user_dict_path(), std::ios::app); + if (dict_file.is_open()) { + dict_file << word << std::endl; + dict_file.close(); + } + } + + scanDocument(); +} + +OnCanvasSpellCheck::~OnCanvasSpellCheck() = default; + +} \ No newline at end of file diff --git a/src/ui/on-canvas-spellcheck.h b/src/ui/on-canvas-spellcheck.h new file mode 100644 index 0000000000000000000000000000000000000000..d399707961036d273ea773ddff32e6127a2bb705 --- /dev/null +++ b/src/ui/on-canvas-spellcheck.h @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifndef INKSCAPE_UI_ON_CANVAS_SPELLCHECK_H +#define INKSCAPE_UI_ON_CANVAS_SPELLCHECK_H + +// #include "ui/libspelling-wrapper.h" + +#include +#include + +#include + +#include "text-editing.h" +#include "util/gobjectptr.h" +#include "display/control/canvas-item-ptr.h" +#include +#include "display/control/canvas-item-squiggle.h" + +class SPObject; +class SPItem; + +namespace Inkscape { +class Preferences; +class CanvasItemSquiggle; +} //namespace Inkscape + + +namespace Inkscape::UI { + +struct MisspelledWord { + Glib::ustring word; + Text::Layout::iterator begin; + Text::Layout::iterator end; + std::vector< CanvasItemPtr > squiggle; + + // Constructor for initialization + MisspelledWord(Glib::ustring w, Text::Layout::iterator b, Text::Layout::iterator e, + std::vector> s = {}) + : word(std::move(w)), begin(b), end(e), squiggle(std::move(s)) {} + + // Move-only + MisspelledWord() = default; + MisspelledWord(MisspelledWord&&) = default; + MisspelledWord& operator=(MisspelledWord&&) = default; + MisspelledWord(const MisspelledWord&) = delete; + MisspelledWord& operator=(const MisspelledWord&) = delete; +}; + +struct TrackedTextItem { + SPItem* item; + sigc::scoped_connection modified_connection; + sigc::scoped_connection release_connection; + std::vector misspelled_words; +}; + +class OnCanvasSpellCheck +{ +public: + OnCanvasSpellCheck(SPDesktop *desktop); + ~OnCanvasSpellCheck(); + +private: + SPObject *_root = nullptr; + SPDesktop *_desktop = nullptr; + + SpellingProvider* _provider = nullptr; + + Util::GObjectPtr _checker; + + std::string _lang_code; + + std::vector _tracked_items; + + std::vector _ignored_words; + + std::vector _added_words; + + // Initialize spell checker and tracked items and start spellchecking + void initialize(); + + // Get all text items in the document + void allTextItems(SPObject *r, std::vector &l, bool hidden, bool locked); + + // Scanning the document for misspelled words + void scanDocument(); + + // Check a specific text item for misspelled words + void checkTextItem(SPItem* item); + + // Create a squiggle for a misspelled word + void createSquiggle(MisspelledWord& misspelled, SPItem* item, const Text::Layout* layout); + + // Object Modified handler + void onObjModified(TrackedTextItem &tracked_item); + + // Object Released handler + void onObjReleased(TrackedTextItem &tracked_item); + +public: + void addTrackedItem(SPItem* item); + + // Check is passed word exists in our MispelledWords vector + bool isMisspelled(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end) const; + + // Get List of Correct Words for SpellCheck + std::vector getCorrections(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end) const; + + void replaceWord(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end, const Glib::ustring &replacement); + + void ignoreOnce(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end); + + void ignore(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end); + + void addToDictionary(SPItem *item, Text::Layout::iterator begin, Text::Layout::iterator end); + + void reinitialize(); + +}; + +} // namespace Inkscape::UI + +#endif // INKSCAPE_UI_ON_CANVAS_SPELLCHECK_H \ No newline at end of file diff --git a/src/ui/tools/text-tool.cpp b/src/ui/tools/text-tool.cpp index 4820de0f76c14a84bf8c38e36d8aafdf352a8e6b..e5cdce0d110a8c55af64807d13fcfc9be609280c 100644 --- a/src/ui/tools/text-tool.cpp +++ b/src/ui/tools/text-tool.cpp @@ -48,6 +48,8 @@ #include "ui/widget/events/debug.h" #include "util/callback-converter.h" #include "util/units.h" +#include "xml/sp-css-attr.h" +#include "ui/on-canvas-spellcheck.h" using Inkscape::DocumentUndo; @@ -139,6 +141,8 @@ TextTool::TextTool(SPDesktop *desktop) if (prefs->getBool("/tools/text/gradientdrag")) { enableGrDrag(); } + + _spellcheck = std::make_unique(_desktop); } TextTool::~TextTool() @@ -271,6 +275,11 @@ void TextTool::_setupText() text_item->updateRepr(); text_item->doWriteTransform(text_item->transform, nullptr, true); DocumentUndo::done(_desktop->getDocument(), _("Create text"), INKSCAPE_ICON("draw-text")); + + if(_spellcheck) + { + _spellcheck->addTrackedItem(text_item); + } } /** diff --git a/src/ui/tools/text-tool.h b/src/ui/tools/text-tool.h index 20fe586d0474460aaae64a614860074221767d32..10c044c90832d80c0bc673a20ed2e8d599713267 100644 --- a/src/ui/tools/text-tool.h +++ b/src/ui/tools/text-tool.h @@ -23,6 +23,8 @@ #include "ui/tools/tool-base.h" #include "util/delete-with.h" +#include "ui/on-canvas-spellcheck.h" + using GtkIMContext = struct _GtkIMContext; namespace Inkscape { @@ -47,6 +49,8 @@ public: bool deleteSelection(); void deleteSelected(); + Inkscape::UI::OnCanvasSpellCheck* getSpellcheck() { return _spellcheck.get();} + SPItem *textItem() const { return text; } // Insertion point position @@ -91,6 +95,8 @@ private: bool creating = false; // dragging rubberband to create flowtext Geom::Point p0; // initial point if the flowtext rect + std::unique_ptr _spellcheck; + sigc::scoped_connection sel_changed_connection; sigc::scoped_connection sel_modified_connection; sigc::scoped_connection style_set_connection;