From 4e4dc86908506572c5566f4ef9f100586b5b3045 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:50:28 +0300 Subject: [PATCH] TXT2MTXT: combine text into MText, with its settings dialog TEXT and MTEXT objects are merged into one MTEXT (top-down or in selection order, word-wrapped or one paragraph per object, with the original pitch kept as line spacing) or each TEXT and MTEXT line becomes an MTEXT of its own. The SEttings option opens the Combine Text settings dialog; the settings persist as profile values and are exposed as the TXT2MTXT* variables and the txt2mtxt_settings automation action. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 18 ++ locales/bg-BG/opencadstudio.ftl | 18 ++ locales/cs-CZ/opencadstudio.ftl | 18 ++ locales/de-DE/opencadstudio.ftl | 18 ++ locales/el-GR/opencadstudio.ftl | 18 ++ locales/en-US/opencadstudio.ftl | 18 ++ locales/es-ES/opencadstudio.ftl | 18 ++ locales/fi-FI/opencadstudio.ftl | 18 ++ locales/fr-FR/opencadstudio.ftl | 18 ++ locales/hi-IN/opencadstudio.ftl | 18 ++ locales/hu-HU/opencadstudio.ftl | 18 ++ locales/it-IT/opencadstudio.ftl | 18 ++ locales/ja-JP/opencadstudio.ftl | 18 ++ locales/ko-KR/opencadstudio.ftl | 18 ++ locales/lo-LA/opencadstudio.ftl | 18 ++ locales/nl-NL/opencadstudio.ftl | 18 ++ locales/pl-PL/opencadstudio.ftl | 18 ++ locales/pt-BR/opencadstudio.ftl | 18 ++ locales/ru-RU/opencadstudio.ftl | 18 ++ locales/tr-TR/opencadstudio.ftl | 18 ++ locales/zh-CN/opencadstudio.ftl | 18 ++ locales/zh-TW/opencadstudio.ftl | 18 ++ src/app/command_driver/mod.rs | 1 + src/app/commands/dim.rs | 73 +++++++ src/app/commands/mod.rs | 4 + src/app/commands/pdf_dialogs.rs | 38 ++++ src/app/commands/styleprops.rs | 48 ++++ src/app/control/actions.rs | 22 ++ src/app/control/mod.rs | 1 + src/app/mod.rs | 8 + src/app/settings.rs | 32 +++ src/app/update/file.rs | 2 + src/app/update/mod.rs | 11 + src/app/view/modal.rs | 7 + src/command.rs | 3 + src/entities/mtext.rs | 17 +- src/modules/annotate/mod.rs | 1 + src/modules/annotate/txt2mtxt.rs | 361 +++++++++++++++++++++++++++++++ src/modules/draw/select.rs | 28 +++ src/ui/window/pdf_dialogs.rs | 195 +++++++++++++++++ 40 files changed, 1244 insertions(+), 4 deletions(-) create mode 100644 src/modules/annotate/txt2mtxt.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 83175dbf1..af9c50fae 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6609,3 +6609,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index cc20285c3..2fd51542e 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6597,3 +6597,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 66afe10f6..a6399a22a 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6594,3 +6594,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 61eef3101..fbc8a5408 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6595,3 +6595,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 07d706269..e951bbf49 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6597,3 +6597,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index e241beee5..5ca28e2d8 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6596,3 +6596,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 9a4f1c86b..20314fc77 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6597,3 +6597,21 @@ ucs-follow = .prompt = UCSFOLLOW: introduce 0 (conservar vista) o 1 (seguir SCP) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activa una ventana de modelo; no se aplica al espacio papel. .invalid = UCSFOLLOW: solo se permite 0 o 1. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 0ec188498..8c3976d0c 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6594,3 +6594,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index ae9d437ba..8404b8707 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6597,3 +6597,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index db5a95696..d13a44c57 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6585,3 +6585,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a31cd56a4..74ed284bd 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6581,3 +6581,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 1d1563b99..18eddc53c 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6596,3 +6596,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 26de504d7..d2813ac5a 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6585,3 +6585,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c43410f04..eafc38b46 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6596,3 +6596,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 8f51fc302..9781ccce1 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6597,3 +6597,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 761118ba4..d8076d2e4 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6595,3 +6595,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 16d72a254..8efd14a80 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6596,3 +6596,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 3ffec2a7b..5e50ee455 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6597,3 +6597,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 3840440f1..a0c15de50 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6598,3 +6598,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2733014ca..1d8aa9b48 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6518,3 +6518,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Metni Birleştir — Ayarlar + .result = Sonuç + .combine = Tek bir MText nesnesinde birleştir + .order = Satır sırası + .top-down = Üstten aşağıya + .selection-order = Seçim sırası + .combine-note = Kapalıyken her metin ayrı MText olur; sıra seçici pasifleşir. + .lines = Satırlar + .wrap = Sözcük kaydırma + .wrap-hint = — satırlar boşlukla birleşir, genişlik hesaplanır + .uniform = Satır aralığını eşitle + .uniform-hint = (sözcük kaydırma açıkken her zaman eşit) + .selected = Seçilen + .result-count = Sonuç · { $count } MText + .apply = Uygula + .help = TXT2MTXT komutunun metin nesnelerini MText olarak nasıl birleştireceğini ayarlar. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 81edca569..ed74d2231 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6580,3 +6580,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index b7941d3ed..d958669c7 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6592,3 +6592,21 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +txt2mtxt = + .title = Combine Text — Settings + .result = Result + .combine = Combine into a single MText object + .order = Line order + .top-down = Top-down + .selection-order = Selection order + .combine-note = When off, each text becomes its own MText; the order choice is inactive. + .lines = Lines + .wrap = Word-wrap + .wrap-hint = — lines join with spaces, the width is computed + .uniform = Force uniform line spacing + .uniform-hint = (always uniform while word-wrap is on) + .selected = Selected + .result-count = Result · { $count } MText + .apply = Apply + .help = Sets how TXT2MTXT combines text objects into MText. diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 5eed4f834..c68293aad 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -445,6 +445,7 @@ impl OpenCADStudio { } => { self.finish_qselect_sample(*state, tab_id, handle); } + CmdResult::OpenTxt2MtxtSettings => self.open_txt2mtxt_settings(), CmdResult::OpenAutoConstrainSettings => { self.handle_open_auto_constrain_settings(); } diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index ba4a89d96..2cc809487 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1335,6 +1335,30 @@ impl OpenCADStudio { } } + // TXT2MTXT — TEXT / MTEXT combined into MTEXT. A preselection is + // used at once; otherwise the selection prompt with SEttings. + "TXT2MTXT" => { + if !self.tabs[i].scene.selected.is_empty() { + return Some(self.dispatch_command("_TXT2MTXTSEL")); + } + use crate::modules::draw::select::SelectObjectsCommand; + self.command_line + .push_output(crate::t!("Select text objects to combine ...").as_ref()); + let command = SelectObjectsCommand::with_settings( + "TXT2MTXT", + "_TXT2MTXTSEL", + "Select objects or [SEttings]:", + || crate::command::CmdResult::OpenTxt2MtxtSettings, + ); + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "_TXT2MTXTSEL" => { + self.apply_txt2mtxt(i); + return Some(self.finish_dispatch("TXT2MTXT")); + } + "OFFSET" => { use crate::modules::draw::modify::offset::{is_offsettable, OffsetCommand}; let all_entities: Vec<_> = self.tabs[i] @@ -1546,3 +1570,52 @@ fn title_case(s: &str) -> String { .collect::>() .join(" ") } + +impl OpenCADStudio { + /// TXT2MTXT on the selection (in selection order), as one undo step. + fn apply_txt2mtxt(&mut self, i: usize) { + let scene = &self.tabs[i].scene; + let handles: Vec = scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .collect(); + let plan = { + let selected: Vec<(codec::Handle, &codec::EntityType)> = handles + .iter() + .filter_map(|h| scene.document.get_entity(*h).map(|e| (*h, e))) + .collect(); + crate::modules::annotate::txt2mtxt::plan( + &selected, + &scene.document, + self.txt2mtxt_settings, + ) + }; + self.tabs[i].scene.deselect_all(); + // Enter on an empty selection ends quietly. + if handles.is_empty() { + return; + } + let Some(plan) = plan else { + self.command_line + .push_output(" 0 Text objects removed, 0 MText objects added."); + return; + }; + self.push_undo_snapshot(i, "TXT2MTXT"); + self.tabs[i].scene.erase_entities(&plan.erase); + let added = plan.add.len(); + for entity in plan.add { + self.tabs[i].scene.add_entity(entity); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + // Every selected object counts as removed text, the way the + // reference reports it. + let removed = handles.len(); + self.command_line.push_output(&if added == 1 { + format!(" {removed} Text objects removed, 1 MText object added.") + } else { + format!(" {removed} Text objects removed, {added} MText objects added.") + }); + } +} diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1b98f6377..c1e83d959 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -770,6 +770,10 @@ inventory::submit!(crate::command::CommandRegistration { "SELECTIONCYCLING", "SURFACEASSOCIATIVITY", "LAYOUTCREATEVIEWPORT", + "TXT2MTXTCOMBINEMTEXT", + "TXT2MTXTTEXTORDER", + "TXT2MTXTWORDWRAP", + "TXT2MTXTLINESPACING", "FIELDEVAL", "PDFIMPORTMODE", "PDFIMPORTFILTER", diff --git a/src/app/commands/pdf_dialogs.rs b/src/app/commands/pdf_dialogs.rs index ebdcf0aa1..048c6828a 100644 --- a/src/app/commands/pdf_dialogs.rs +++ b/src/app/commands/pdf_dialogs.rs @@ -348,6 +348,12 @@ impl OpenCADStudio { self.active_modal = Some(crate::app::ModalKind::PdfImportSettings); } + /// TXT2MTXT's SEttings option: the dialog over the running command. + pub(in crate::app) fn open_txt2mtxt_settings(&mut self) { + self.txt2mtxt_dialog = Some(self.txt2mtxt_settings); + self.active_modal = Some(crate::app::ModalKind::Txt2MtxtSettings); + } + /// PDFIMPORT's File option after the file is chosen. pub(in crate::app) fn open_pdf_import_file(&mut self, path: &str) { self.pdf_import_file = Some(PdfImportFileState { @@ -442,6 +448,7 @@ impl OpenCADStudio { crate::t!("Sets the color schemes and ranges a point cloud is colored by intensity or elevation.") } "layers" => crate::t!("Turns the layers of a PDF underlay on or off."), + "txt2mtxt" => std::borrow::Cow::Owned(crate::tr!("txt2mtxt", "help")), _ => crate::t!("Imports the geometry, fills, raster images and text of a PDF file as drawing objects."), }; self.command_line.push_info(text.as_ref()); @@ -726,6 +733,37 @@ impl OpenCADStudio { self.close_active_modal(); } + // ── Text to MText Settings ──────────────────────────────────── + PdfDialogMsg::T2mCombine(on) => { + if let Some(s) = self.txt2mtxt_dialog.as_mut() { + s.combine = on; + } + } + PdfDialogMsg::T2mSelectOrder(on) => { + if let Some(s) = self.txt2mtxt_dialog.as_mut() { + s.select_order = on; + } + } + PdfDialogMsg::T2mWrap(on) => { + if let Some(s) = self.txt2mtxt_dialog.as_mut() { + s.word_wrap = on; + } + } + PdfDialogMsg::T2mUniform(on) => { + if let Some(s) = self.txt2mtxt_dialog.as_mut() { + s.uniform_spacing = on; + } + } + PdfDialogMsg::T2mOk => { + if let Some(s) = self.txt2mtxt_dialog { + if s != self.txt2mtxt_settings { + self.txt2mtxt_settings = s; + self.save_config(); + } + } + self.close_active_modal(); + } + // ── Import PDF ──────────────────────────────────────────────── PdfDialogMsg::ImportBrowse => return Task::done(Message::PdfImportPick), PdfDialogMsg::ImportPage(page) => { diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 42d3d8d9c..4a8c240ee 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1281,6 +1281,10 @@ impl OpenCADStudio { | "DYNCONSTRAINTDISPLAY" | "CCONSTRAINTFORM" | "DYNMODE" + | "TXT2MTXTCOMBINEMTEXT" + | "TXT2MTXTTEXTORDER" + | "TXT2MTXTWORDWRAP" + | "TXT2MTXTLINESPACING" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1577,6 +1581,50 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if let Some(field) = match name.as_str() { + "TXT2MTXTCOMBINEMTEXT" => Some(0), + "TXT2MTXTTEXTORDER" => Some(1), + "TXT2MTXTWORDWRAP" => Some(2), + "TXT2MTXTLINESPACING" => Some(3), + _ => None, + } { + // TXT2MTXT's profile settings: 0 / 1 (TEXTORDER 1 = + // selection order). + let s = &mut self.txt2mtxt_settings; + let slot = match field { + 0 => &mut s.combine, + 1 => &mut s.select_order, + 2 => &mut s.word_wrap, + _ => &mut s.uniform_spacing, + }; + let current = i16::from(*slot); + match value + .as_deref() + .map(|v| v.trim().parse::().ok().filter(|v| (0..=1).contains(v))) + { + Some(Some(mode)) => { + if current != mode { + *slot = mode == 1; + self.save_config(); + } + } + Some(None) => { + self.command_line + .push_error(crate::t!("Requires 0 or 1 only.").as_ref()); + self.command_line.push_output(&format!( + "Enter new value for {name} <{current}>:" + )); + self.pending_setvar = Some(name.clone()); + } + None => { + self.command_line.push_output(&format!( + "Enter new value for {name} <{current}>:" + )); + self.pending_setvar = Some(name.clone()); + } + } + return Some(self.finish_dispatch(cmd)); + } if name == "FIELDDISPLAY" { let current = i16::from(crate::entities::field::display()); match value diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index aa397d1f4..917be4866 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -103,6 +103,7 @@ pub(super) const NAMES: &[&str] = &[ #[cfg(not(target_arch = "wasm32"))] "external_change_cancel", "pdf_dialog_ok", + "txt2mtxt_settings", "view_background", "image_adjust", "refedit_dialog", @@ -2088,6 +2089,27 @@ impl OpenCADStudio { _ => return Err(bad()), } } + // Text to MText Settings (TXT2MTXT SEttings): "combine=0", + // "order=select" / "order=top", "wrap=0", "uniform=1", "ok", + // "cancel". + "txt2mtxt_settings" => { + use crate::ui::window::pdf_dialogs::PdfDialogMsg as M; + if self.active_modal != Some(crate::app::ModalKind::Txt2MtxtSettings) { + return Err(failure("no_dialog", "Text to MText Settings is not open")); + } + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + let on = arg != "0"; + match key { + "combine" => Message::PdfDialog(M::T2mCombine(on)), + "order" => Message::PdfDialog(M::T2mSelectOrder(arg == "select")), + "wrap" => Message::PdfDialog(M::T2mWrap(on)), + "uniform" => Message::PdfDialog(M::T2mUniform(on)), + "ok" => Message::PdfDialog(M::T2mOk), + "cancel" => Message::CloseModal, + _ => return Err(failure("invalid_value", "Unknown setting")), + } + } "pdf_dialog_ok" => { use crate::ui::window::pdf_dialogs::PdfDialogMsg; Message::PdfDialog(match self.active_modal { diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 28b98b3e2..f44fdc90f 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -381,6 +381,7 @@ fn command_selection_policy(name: &str) -> &'static str { "ERASE", "EXPLODE", "TXTEXP", + "TXT2MTXT", "GROUP", "LAYFRZ", "LAYLCK", diff --git a/src/app/mod.rs b/src/app/mod.rs index 8e3bebe25..f28cf944b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -764,6 +764,10 @@ pub(super) struct OpenCADStudio { /// pill shows only the glyph. pub show_constraint_values: bool, pub auto_constrain_settings: settings::AutoConstrainSettings, + /// TXT2MTXT settings (profile). + pub txt2mtxt_settings: settings::Txt2MtxtSettings, + /// The Text to MText Settings dialog's working copy. + txt2mtxt_dialog: Option, auto_constrain_saved: Option, auto_constrain_selected_row: usize, auto_constrain_distance_input: String, @@ -2178,6 +2182,8 @@ pub enum ModalKind { RefEdit, UnderlayLayers, PdfImportSettings, + /// TXT2MTXT's Text to MText Settings, over the running command. + Txt2MtxtSettings, PdfImportFile, XrefAttach, WriteBlock, @@ -4693,6 +4699,8 @@ impl OpenCADStudio { file_assoc_enabled: true, show_constraint_values: true, auto_constrain_settings: settings::AutoConstrainSettings::default(), + txt2mtxt_settings: settings::Txt2MtxtSettings::default(), + txt2mtxt_dialog: None, auto_constrain_saved: None, auto_constrain_selected_row: 0, auto_constrain_distance_input: "0.05".to_string(), diff --git a/src/app/settings.rs b/src/app/settings.rs index 2726411a4..8d6a0138c 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -108,6 +108,34 @@ impl AutoConstrainSettings { } } +/// TXT2MTXT (Combine Text) settings, the profile values +/// TXT2MTXTCOMBINEMTEXT, TXT2MTXTTEXTORDER, TXT2MTXTWORDWRAP and +/// TXT2MTXTLINESPACING. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Txt2MtxtSettings { + /// Combine everything into one MTEXT (else each TEXT becomes its own). + pub combine: bool, + /// Keep the selection order (else sort top-down). + pub select_order: bool, + /// Run the lines on separated by spaces in a computed width (else one + /// paragraph per object). + pub word_wrap: bool, + /// Force uniform line spacing (always on while word-wrap is). + pub uniform_spacing: bool, +} + +impl Default for Txt2MtxtSettings { + fn default() -> Self { + Self { + combine: true, + select_order: false, + word_wrap: true, + uniform_spacing: true, + } + } +} + /// Cursor shown over the drawing viewport. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CursorType { @@ -568,6 +596,9 @@ pub struct UserSettings { /// the Auto Constrain command. #[serde(default)] pub auto_constrain: AutoConstrainSettings, + /// TXT2MTXT settings (Text to MText Settings dialog). + #[serde(default)] + pub txt2mtxt: Txt2MtxtSettings, /// Keep existing geometry size while solving after a constraint edit. #[serde(default = "default_constraint_solve_mode")] pub constraint_solve_mode: bool, @@ -962,6 +993,7 @@ impl Default for UserSettings { file_assoc_enabled: true, show_constraint_values: true, auto_constrain: AutoConstrainSettings::default(), + txt2mtxt: Txt2MtxtSettings::default(), constraint_solve_mode: true, constraint_infer: false, constraint_bar_display: 3, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c04a26005..979dd1dbe 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -862,6 +862,7 @@ impl OpenCADStudio { file_assoc_enabled: self.file_assoc_enabled, show_constraint_values: self.show_constraint_values, auto_constrain: self.auto_constrain_settings.clone(), + txt2mtxt: self.txt2mtxt_settings, constraint_solve_mode: self.constraint_solve_mode, constraint_infer: self.constraint_infer, constraint_bar_display: self.constraint_bar_display, @@ -1031,6 +1032,7 @@ impl OpenCADStudio { self.file_assoc_enabled = s.file_assoc_enabled; self.show_constraint_values = s.show_constraint_values; self.auto_constrain_settings = s.auto_constrain.clone(); + self.txt2mtxt_settings = s.txt2mtxt; self.auto_constrain_settings.sanitize(); self.auto_constrain_distance_input = format!("{}", self.auto_constrain_settings.distance_tolerance); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bcfb76142..ed87dd1be 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -237,6 +237,17 @@ impl OpenCADStudio { Some(PdfAttach) => self.pdf_attach = None, Some(UnderlayLayers) => self.underlay_layers = None, Some(PdfImportSettings) => self.pdf_import_settings = None, + // OK or Cancel: TXT2MTXT asks for its selection again. + Some(Txt2MtxtSettings) => { + self.txt2mtxt_dialog = None; + if let Some(prompt) = self.tabs[self.active_tab] + .active_cmd + .as_ref() + .map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + } Some(PdfImportFile) => self.pdf_import_file = None, Some(XrefAttach) => self.xref_attach = None, Some(WriteBlock) => self.wblock = None, diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index dfb36033d..cdb1f6b89 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -46,6 +46,7 @@ impl OpenCADStudio { Some(K::RefEdit) => crate::tr!("ref-edit", "dialog-title"), Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(), Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(), + Some(K::Txt2MtxtSettings) => crate::tr!("txt2mtxt", "title"), Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(), Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(), Some(K::WriteBlock) => crate::t!("Write Block").into_owned(), @@ -575,6 +576,12 @@ impl OpenCADStudio { crate::ui::window::pdf_dialogs::view_import_settings(settings, flow) }) } + super::super::ModalKind::Txt2MtxtSettings => { + let settings = self.txt2mtxt_dialog.as_ref()?; + sized_flow(ex, 600, 470, |flow| { + crate::ui::window::pdf_dialogs::view_txt2mtxt_settings(settings, flow) + }) + } super::super::ModalKind::PdfImportFile => { let state = self.pdf_import_file.as_ref()?; sized_flow(ex, 960, 560, |flow| { diff --git a/src/command.rs b/src/command.rs index e62e98500..11e5dd6a4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1812,6 +1812,9 @@ pub enum CmdResult { second: crate::scene::parametric_constraints::ParametricRef, label: &'static str, }, + /// Opens the Text to MText Settings dialog from TXT2MTXT's selection + /// prompt; the command keeps its prompt. + OpenTxt2MtxtSettings, /// Opens the Auto Constrain settings dialog from the selection prompt. OpenAutoConstrainSettings, /// Adds an ordered Coincident relation. Point/point selections create a diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 54f91cc37..6b73ae6e3 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -114,6 +114,16 @@ fn display_text(t: &MText, document: &codec::CadDocument) -> String { /// click-to-select preview. Uses the exact same layout opts as `to_render` /// so the boxes line up with the rendered glyphs. pub fn glyph_boxes(t: &MText, document: &codec::CadDocument) -> Vec { + layout_of(t, document, true).glyph_boxes +} + +/// The MTEXT laid out as it is drawn: its sub-lines, their widths and the +/// first baseline, in the entity's own (pre-rotation) frame. +pub fn layout_of( + t: &MText, + document: &codec::CadDocument, + want_glyph_boxes: bool, +) -> crate::entities::text_support::MTextLayout { let resolved_style = resolve_text_style(&t.style, document); let attach_h_anchor: f32 = match t.attachment_point { AttachmentPoint::TopCenter @@ -143,7 +153,7 @@ pub fn glyph_boxes(t: &MText, document: &codec::CadDocument) -> Vec { // A field shows its stored value: fields update on their evaluation // events (open, save, plot, regen, UPDATEFIELD), never on a redraw. let display = display_text(t, document); - let layout = layout_mtext(&MTextRenderOpts { + layout_mtext(&MTextRenderOpts { value: &display, insertion: [ t.insertion_point.x, @@ -165,10 +175,9 @@ pub fn glyph_boxes(t: &MText, document: &codec::CadDocument) -> Vec { vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) || matches!(t.drawing_direction, DrawingDirection::ByStyle) && resolved_style.is_vertical, - want_glyph_boxes: true, + want_glyph_boxes, columns: columns_of(t), - }); - layout.glyph_boxes + }) } fn to_render(t: &MText, document: &codec::CadDocument) -> RenderEntity { diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index 5afa9ef02..db7fb38b4 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -29,6 +29,7 @@ pub mod radius_dim; pub mod table_cmd; pub mod text; pub mod textedit; +pub mod txt2mtxt; pub mod tolerance_cmd; use crate::modules::{CadModule, RibbonGroup, RibbonItem, StyleKey}; diff --git a/src/modules/annotate/txt2mtxt.rs b/src/modules/annotate/txt2mtxt.rs new file mode 100644 index 000000000..c0970a8aa --- /dev/null +++ b/src/modules/annotate/txt2mtxt.rs @@ -0,0 +1,361 @@ +//! TXT2MTXT (Combine Text): TEXT and MTEXT objects merged into one MTEXT, or +//! each TEXT (and each line of an MTEXT) turned into an MTEXT of its own. +//! +//! The combined MTEXT sits at the top-left corner of the first object (the +//! topmost one unless the selection order is kept) and takes its layer, style, +//! height and rotation. With word-wrap the objects run on separated by spaces +//! in a box as wide as their bounding box (plus half a percent), and an even +//! pitch between them becomes the line spacing factor. Without it each object +//! starts a paragraph; unless line spacing is forced uniform, each paragraph +//! keeps its own pitch (`\pxsm` / `\psm`). A height change starts `\H;`. + +use codec::entities::{ + AttachmentPoint, DrawingDirection, LineSpacingStyle, MText, TextHorizontalAlignment as HA, +}; +use codec::types::Vector3; +use codec::{CadDocument, EntityType, Handle}; + +use crate::app::settings::Txt2MtxtSettings; +use crate::entities::text_support::{resolve_dxf_special_chars, text_local_bounds}; + +/// What the command does to the drawing. +pub struct Txt2MtxtPlan { + pub erase: Vec, + pub add: Vec, +} + +/// One TEXT or MTEXT of the selection, in its own frame. +struct Piece<'a> { + handle: Handle, + entity: &'a EntityType, + /// MTEXT-encoded value. + content: String, + height: f64, + /// The frame's origin (WCS) and direction. + origin: [f64; 2], + rotation: f64, + /// Box in the frame: left, right, last baseline, top. + rect: [f64; 4], +} + +impl Piece<'_> { + fn point(&self, x: f64, y: f64) -> [f64; 2] { + let (s, c) = self.rotation.sin_cos(); + [ + self.origin[0] + x * c - y * s, + self.origin[1] + x * s + y * c, + ] + } + + fn top_left(&self) -> [f64; 2] { + self.point(self.rect[0], self.rect[3]) + } + + fn corners(&self) -> [[f64; 2]; 4] { + let [l, r, b, t] = self.rect; + [ + self.point(l, b), + self.point(r, b), + self.point(r, t), + self.point(l, t), + ] + } + + /// First and last baselines, as heights in a frame turned by `angle`. + fn baselines(&self, angle: f64) -> (f64, f64) { + let [l, _, b, t] = self.rect; + ( + up(self.point(l, t - self.height), angle), + up(self.point(l, b), angle), + ) + } +} + +/// Height of a WCS point in a frame turned by `angle`. +fn up(p: [f64; 2], angle: f64) -> f64 { + let (s, c) = angle.sin_cos(); + -p[0] * s + p[1] * c +} + +/// A TEXT value as MTEXT content: `%%u` / `%%o` become underline / overline +/// switches, braces are escaped, other `%%` codes stay, and a backslash is +/// dropped unless it starts a `\U+` / `\M+` character code. +fn text_content(value: &str) -> String { + let chars: Vec = value.chars().collect(); + let (mut under, mut over) = (false, false); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + let ch = chars[i]; + let next = chars.get(i + 1).copied(); + if ch == '%' && next == Some('%') { + match chars.get(i + 2).map(|c| c.to_ascii_lowercase()) { + Some('u') => { + out.push_str(if under { "\\l" } else { "\\L" }); + under = !under; + i += 3; + continue; + } + Some('o') => { + out.push_str(if over { "\\o" } else { "\\O" }); + over = !over; + i += 3; + continue; + } + _ => {} + } + } + match ch { + '{' | '}' => { + out.push('\\'); + out.push(ch); + } + '\\' if matches!(next, Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => { + out.push(ch) + } + '\\' => {} + _ => out.push(ch), + } + i += 1; + } + out +} + +fn piece<'a>(handle: Handle, entity: &'a EntityType, doc: &CadDocument) -> Option> { + match entity { + EntityType::Text(t) => { + let p = crate::entities::text::text_run_placement_at_scale(t, doc, 1.0); + let advance = text_local_bounds( + &p.font, + &resolve_dxf_special_chars(&t.value), + p.height, + p.width_factor, + p.oblique_angle, + ) + .map_or(0.0, |b| b.advance.abs() as f64); + Some(Piece { + handle, + entity, + content: text_content(&t.value), + height: p.height as f64, + origin: p.origin, + rotation: p.rotation as f64, + rect: [0.0, advance, 0.0, p.height as f64], + }) + } + EntityType::MText(m) => { + let layout = crate::entities::mtext::layout_of(m, doc, false); + let width = layout.line_widths.iter().fold(0.0_f32, |a, w| a.max(*w)) as f64; + let anchor = match m.attachment_point { + AttachmentPoint::TopCenter + | AttachmentPoint::MiddleCenter + | AttachmentPoint::BottomCenter => 0.5, + AttachmentPoint::TopRight + | AttachmentPoint::MiddleRight + | AttachmentPoint::BottomRight => 1.0, + _ => 0.0, + }; + // The block runs from the first line's cap height down to the + // last baseline, hung from the attachment point. + let lines = layout.line_count.max(1); + let block = m.height + (lines - 1) as f64 * layout.line_height as f64; + let top = match m.attachment_point { + AttachmentPoint::MiddleLeft + | AttachmentPoint::MiddleCenter + | AttachmentPoint::MiddleRight => block / 2.0, + AttachmentPoint::BottomLeft + | AttachmentPoint::BottomCenter + | AttachmentPoint::BottomRight => block, + _ => 0.0, + }; + let left = -anchor * width; + Some(Piece { + handle, + entity, + content: m.value.clone(), + height: m.height, + origin: [m.insertion_point.x, m.insertion_point.y], + rotation: m.rotation, + rect: [left, left + width, top - block, top], + }) + } + _ => None, + } +} + +/// A new MTEXT with `source`'s layer, colour and style. +fn mtext_from(source: &EntityType, value: String, at: [f64; 2], height: f64, rotation: f64) -> MText { + let mut m = MText::new(); + let (common, style, z, normal) = match source { + EntityType::Text(t) => (&t.common, &t.style, t.insertion_point.z, t.normal), + EntityType::MText(t) => (&t.common, &t.style, t.insertion_point.z, t.normal), + _ => unreachable!("pieces are TEXT or MTEXT"), + }; + m.common = common.clone(); + m.common.handle = Handle::NULL; + m.common.reactors.clear(); + m.common.xdictionary_handle = None; + m.style = style.clone(); + m.normal = normal; + m.value = value; + m.insertion_point = Vector3::new(at[0], at[1], z); + m.height = height; + m.rotation = rotation; + m.rectangle_width = 0.0; + m.attachment_point = AttachmentPoint::TopLeft; + m.drawing_direction = DrawingDirection::ByStyle; + m.line_spacing_style = LineSpacingStyle::AtLeast; + m.line_spacing_factor = 1.0; + m +} + +/// Each TEXT and each line of an MTEXT as an MTEXT of its own. A TEXT keeps +/// its horizontal justification as a top attachment. +fn separate(p: &Piece, doc: &CadDocument) -> Vec { + match p.entity { + EntityType::Text(t) => { + let (x, attachment) = match t.horizontal_alignment { + HA::Center | HA::Middle => (0.5, AttachmentPoint::TopCenter), + HA::Right => (1.0, AttachmentPoint::TopRight), + _ => (0.0, AttachmentPoint::TopLeft), + }; + let at = p.point(p.rect[0] + x * (p.rect[1] - p.rect[0]), p.rect[3]); + let mut m = mtext_from(p.entity, p.content.clone(), at, p.height, p.rotation); + m.attachment_point = attachment; + vec![EntityType::MText(m)] + } + EntityType::MText(m) => { + // The drawn words, grouped into lines by their baseline. + // ponytail: a line keeps only its words, not inline formatting. + let words = crate::entities::mtext::explode_mtext(m, doc); + let mut lines: Vec<(f64, Vec<(f64, &codec::entities::Text)>)> = Vec::new(); + for word in &words { + let EntityType::Text(t) = word else { continue }; + let at = [t.insertion_point.x, t.insertion_point.y]; + let (s, c) = m.rotation.sin_cos(); + let along = at[0] * c + at[1] * s; + let v = up(at, m.rotation); + match lines + .iter_mut() + .find(|(base, _)| (base - v).abs() < m.height * 0.25) + { + Some((_, line)) => line.push((along, t)), + None => lines.push((v, vec![(along, t)])), + } + } + lines.sort_by(|a, b| b.0.total_cmp(&a.0)); + // Line k hangs one line step below line k-1 from the block's top. + let step = (p.rect[3] - p.rect[2] - p.height) / (lines.len().max(2) - 1) as f64; + let origin_along = { + let (s, c) = m.rotation.sin_cos(); + p.origin[0] * c + p.origin[1] * s + }; + lines + .into_iter() + .enumerate() + .map(|(k, (_, mut line))| { + line.sort_by(|a, b| a.0.total_cmp(&b.0)); + let value = line + .iter() + .map(|(_, t)| t.value.as_str()) + .collect::>() + .join(" "); + let at = p.point(line[0].0 - origin_along, p.rect[3] - k as f64 * step); + EntityType::MText(mtext_from(p.entity, value, at, line[0].1.height, m.rotation)) + }) + .collect() + } + _ => Vec::new(), + } +} + +/// The edit for `selected` (in selection order); `None` when nothing in it is +/// text. +pub fn plan( + selected: &[(Handle, &EntityType)], + doc: &CadDocument, + settings: Txt2MtxtSettings, +) -> Option { + let mut pieces: Vec = selected + .iter() + .filter_map(|(h, e)| piece(*h, e, doc)) + .collect(); + let frame = pieces.first()?.rotation; + if !settings.combine { + return Some(Txt2MtxtPlan { + erase: pieces.iter().map(|p| p.handle).collect(), + add: pieces.iter().flat_map(|p| separate(p, doc)).collect(), + }); + } + if !settings.select_order { + // Top-down; a tie keeps the selection order. + let top = |p: &Piece| p.corners().map(|c| up(c, frame)).into_iter().fold(f64::MIN, f64::max); + pieces.sort_by(|a, b| top(b).total_cmp(&top(a))); + } + let first = &pieces[0]; + let frame = first.rotation; + // Each paragraph's pitch to the next one, as a factor of the default + // step (5/3 of its text height). + let pitches: Vec = pieces + .windows(2) + .map(|w| { + let gap = w[0].baselines(frame).1 - w[1].baselines(frame).0; + (gap / (w[0].height * 5.0 / 3.0)).clamp(0.25, 4.0) + }) + .collect(); + let per_paragraph = !settings.word_wrap && !settings.uniform_spacing; + let mut value = String::new(); + let mut current = first.height; + for (i, p) in pieces.iter().enumerate() { + if i > 0 { + value.push_str(if settings.word_wrap { " " } else { "\\P" }); + } + if let (true, Some(pitch)) = (per_paragraph, pitches.get(i)) { + let x = if i == 0 { "x" } else { "" }; + value.push_str(&format!("\\p{x}sm{pitch:.2};")); + } + if (p.height - current).abs() > 1e-9 { + value.push_str(&format!("\\H{:.4};", p.height)); + current = p.height; + } + value.push_str(&p.content); + } + let mut m = mtext_from(first.entity, value, first.top_left(), first.height, frame); + if settings.word_wrap { + // The objects' WCS bounding box, measured along the first one. + let mut bounds = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for c in pieces.iter().flat_map(|p| p.corners()) { + bounds = [ + bounds[0].min(c[0]), + bounds[1].min(c[1]), + bounds[2].max(c[0]), + bounds[3].max(c[1]), + ]; + } + let (s, c) = frame.sin_cos(); + let along = [ + [bounds[0], bounds[1]], + [bounds[2], bounds[1]], + [bounds[2], bounds[3]], + [bounds[0], bounds[3]], + ] + .map(|p| p[0] * c + p[1] * s); + let span = along.iter().fold(f64::MIN, |a, v| a.max(*v)) + - along.iter().fold(f64::MAX, |a, v| a.min(*v)); + m.rectangle_width = span * 1.005; + // An even pitch carries over; an uneven one falls back to single. + if let Some(&pitch) = pitches.first() { + if pitches.iter().all(|p| (p - pitch).abs() < 1e-4) { + m.line_spacing_factor = pitch; + } + } + } + Some(Txt2MtxtPlan { + erase: pieces.iter().map(|p| p.handle).collect(), + add: vec![EntityType::MText(m)], + }) +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["TXT2MTXT"] +}); diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 76417cfb5..392ecf54f 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -44,6 +44,8 @@ pub struct SelectObjectsCommand { /// The kinds of object the command takes; the host drops the rest of /// each completed selection silently, and Enter on nothing ends quietly. keep: Option bool>, + /// A SEttings keyword at the prompt, and what it does. + settings: Option CmdResult>, } impl SelectObjectsCommand { @@ -61,6 +63,7 @@ impl SelectObjectsCommand { prompt_text: None, associative_dimensions_only: false, keep: None, + settings: None, } } @@ -79,6 +82,7 @@ impl SelectObjectsCommand { prompt_text: None, associative_dimensions_only: false, keep: None, + settings: None, } } @@ -97,6 +101,7 @@ impl SelectObjectsCommand { prompt_text: None, associative_dimensions_only: false, keep: None, + settings: None, } } @@ -115,6 +120,7 @@ impl SelectObjectsCommand { prompt_text: None, associative_dimensions_only: false, keep: None, + settings: None, } } @@ -150,6 +156,19 @@ impl SelectObjectsCommand { command } + /// The command's own wording with a SEttings keyword; Enter on nothing + /// ends quietly. + pub fn with_settings( + prompt_cmd: &str, + pending_cmd: &str, + prompt_text: &'static str, + settings: fn() -> CmdResult, + ) -> Self { + let mut command = Self::filtered(prompt_cmd, pending_cmd, prompt_text, |_| true); + command.settings = Some(settings); + command + } + pub fn auto_constrain(pending_cmd: &str) -> Self { let mut command = Self::new(pending_cmd); command.auto_constrain_settings = true; @@ -242,6 +261,9 @@ impl CadCommand for SelectObjectsCommand { // they work typed as well — these buttons only surface them, which is what // the on-screen keyboard-less case needs. fn options(&self) -> Vec { + if self.settings.is_some() && self.pick.is_none() { + return vec![CmdOption::new("SEttings", "SE")]; + } if self.commit_on_enter && self.show_options { if self.auto_constrain_settings { return vec![CmdOption::new(t!("Settings").as_ref(), "S")]; @@ -297,6 +319,12 @@ impl CadCommand for SelectObjectsCommand { if !self.commit_on_enter { return None; } + let word = text.trim().trim_start_matches('_').to_uppercase(); + if let Some(settings) = self.settings { + if word.len() >= 2 && "SETTINGS".starts_with(word.as_str()) { + return Some(settings()); + } + } match text.trim().to_uppercase().as_str() { "S" | "SETTINGS" if self.auto_constrain_settings => { Some(CmdResult::OpenAutoConstrainSettings) diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index 1afa5446b..957417eef 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -101,6 +101,13 @@ pub enum PdfDialogMsg { Lineweights(bool), Linetypes(bool), SettingsOk, + // Text to MText Settings (TXT2MTXT) + T2mCombine(bool), + /// Keep the selection order (else top-down). + T2mSelectOrder(bool), + T2mWrap(bool), + T2mUniform(bool), + T2mOk, // Import PDF ImportBrowse, ImportPage(usize), @@ -1261,6 +1268,194 @@ pub fn view_import_settings<'a>( .into() } +// ── Text to MText Settings (TXT2MTXT) ────────────────────────────────────── + +/// A labelled switch; inactive (dimmed) while `on` is None. +fn t2m_switch<'a>( + label: String, + hint: String, + value: bool, + on: Option PdfDialogMsg>, +) -> Element<'a, Message> { + let enabled = on.is_some(); + let mut switch = iced::widget::toggler(value).size(18.0); + if let Some(on) = on { + switch = switch.on_toggle(move |v| msg(on(v))); + } + let dim = move |theme: &Theme| text::Style { + color: Some(theme.palette().background.strong.color), + }; + let name = text(label).size(12); + let name = if enabled { name } else { name.style(dim) }; + row![ + switch, + name, + text(hint).size(11).style(if enabled { muted_style } else { dim }), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// Top-down or selection order, as joined buttons; inactive without `enabled`. +fn t2m_order<'a>(select_order: bool, enabled: bool) -> Element<'a, Message> { + let choice = |value: bool, label: String| -> Element<'a, Message> { + let chosen = enabled && value == select_order; + button(text(label).size(11).width(Fill).align_x(iced::Center)) + .on_press_maybe(enabled.then(|| msg(PdfDialogMsg::T2mSelectOrder(value)))) + .style(move |theme: &Theme, status| { + let mut style = button_style(chosen)(theme, status); + if !enabled { + style.text_color = theme.palette().background.strong.color; + } + style + }) + .padding([5, 6]) + .width(Fill) + .into() + }; + container( + row![ + choice(false, crate::tr!("txt2mtxt", "top-down")), + choice(true, crate::tr!("txt2mtxt", "selection-order")) + ] + .spacing(2), + ) + .padding(2) + .width(Length::Fixed(300.0)) + .style(well_style) + .into() +} + +/// A text sample in a well, one line per entry. +fn t2m_sample<'a>(lines: Vec, framed: bool, width: f32) -> Element<'a, Message> { + let body = iced::widget::Column::with_children( + lines + .into_iter() + .map(|l| text(l).size(12).font(iced::Font::MONOSPACE).into()) + .collect::>(), + ) + .spacing(6); + let inner = container(body).padding([6, 8]).width(Fill); + let inner: Element<'a, Message> = if framed { + inner + .style(|theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 2.0.into(), + color: theme.palette().primary.base.color, + }, + ..Default::default() + }) + .into() + } else { + inner.into() + }; + container(inner) + .padding(6) + .width(Length::Fixed(width)) + .height(Length::Fixed(86.0)) + .style(well_style) + .into() +} + +pub fn view_txt2mtxt_settings<'a>( + s: &'a crate::app::settings::Txt2MtxtSettings, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let result = card( + crate::tr!("txt2mtxt", "result"), + column![ + t2m_switch( + crate::tr!("txt2mtxt", "combine"), + String::new(), + s.combine, + Some(PdfDialogMsg::T2mCombine), + ), + column![ + text(crate::tr!("txt2mtxt", "order")).size(11).style(muted_style), + t2m_order(s.select_order, s.combine), + ] + .spacing(6) + .padding(iced::Padding::ZERO.left(44)), + text(crate::tr!("txt2mtxt", "combine-note")) + .size(11) + .style(muted_style), + ] + .spacing(8), + ); + let lines = card( + crate::tr!("txt2mtxt", "lines"), + column![ + t2m_switch( + crate::tr!("txt2mtxt", "wrap"), + crate::tr!("txt2mtxt", "wrap-hint"), + s.word_wrap, + s.combine.then_some(PdfDialogMsg::T2mWrap as fn(bool) -> PdfDialogMsg), + ), + t2m_switch( + crate::tr!("txt2mtxt", "uniform"), + crate::tr!("txt2mtxt", "uniform-hint"), + s.uniform_spacing || s.word_wrap, + (s.combine && !s.word_wrap) + .then_some(PdfDialogMsg::T2mUniform as fn(bool) -> PdfDialogMsg), + ), + ] + .spacing(10), + ); + let sample: Vec = ["First line", "Second line", "Third line"] + .into_iter() + .map(String::from) + .collect(); + let (count, out) = if !s.combine { + (3, sample.clone()) + } else if s.word_wrap { + ( + 1, + vec!["First line Second line Third".into(), "line".into()], + ) + } else { + (1, sample.clone()) + }; + let preview = container( + row![ + column![ + text(crate::tr!("txt2mtxt", "selected")).size(11).style(muted_style), + t2m_sample(sample, false, 190.0) + ] + .spacing(4), + text("→").size(20).style(accent_text), + column![ + text(crate::tr!("txt2mtxt", "result-count", count = count)) + .size(11) + .style(muted_style), + t2m_sample(out, true, 262.0) + ] + .spacing(4), + ] + .spacing(14) + .align_y(iced::Center), + ) + .padding([10, 12]) + .width(Fill) + .style(card_style); + column![ + result, + lines, + preview, + footer( + "txt2mtxt", + None, + crate::tr!("txt2mtxt", "apply").into(), + PdfDialogMsg::T2mOk, + ), + ] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} + // ── Import PDF (File) ────────────────────────────────────────────────────── pub struct PdfImportFileState {