Merge pull request #1814 from ramox81/codex/pdf-shx-text

PDFSHXTEXT: recognise SHX text geometry as MText

# Conflicts:
#	locales/en-US/opencadstudio.ftl
#	locales/tr-TR/opencadstudio.ftl
#	src/app/commands/blocks.rs
#	src/app/control/actions.rs
#	src/modules/draw/select.rs
This commit is contained in:
Hakan Seven committed 2026-10-15 21:39:53 +03:00
commit 1efc82bad2
36 files changed
+2510

No files matched your search

+1
View File
@@ -749,6 +749,7 @@ impl OpenCADStudio {
self.open_blocks_palette(Some(tab));
}
cmd if self.dispatch_pdf_shx_text(cmd, i) => {}
"PDFIMPORT" | "-PDFIMPORT" => {
use crate::command::CadCommand;
use crate::modules::insert::pdf_import::PdfImportCommand;
+4
View File
@@ -24,6 +24,7 @@ mod pc_colormap;
mod pc_extract;
mod pdf_dialogs;
pub(crate) mod pdf_import;
mod pdf_shx_text;
mod pdf_underlay;
mod plotvars;
mod point_cloud;
@@ -819,6 +820,9 @@ inventory::submit!(crate::command::CommandRegistration {
"PDFIMPORTFILTER",
"PDFIMPORTLAYERS",
"PDFIMPORTIMAGEPATH",
"PDFSHXLAYER",
"PDFSHXBESTFONT",
"PDFSHXTEXTSETTINGS",
"XDWGFADECTL",
"POINTCLOUDCLIPFRAME",
"POINTCLOUDDENSITY",
+327
View File
@@ -0,0 +1,327 @@
//! PDFSHXTEXT: the selection, the settings (dialog or command line) and the
//! conversion of the recognised lines to MTEXT as one undo step.
use super::*;
use crate::modules::insert::pdf_shx_text::{
self as shx, ShxSettingsCommand, Stroke, APPLY, OPEN_SETTINGS,
};
use crate::ui::window::pdf_shx_dialog::{ShxDialogMsg, ShxSettingsState};
const INTRO: &str = "Select geometry to convert to text...";
impl OpenCADStudio {
fn drawing_path(&self, i: usize) -> Option<String> {
self.tabs[i]
.current_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.or_else(|| self.tabs[i].scene.document.source_path.clone())
}
/// PDFSHXTEXT: a pre-selection converts at once; otherwise the objects are
/// asked for. PDFSHXTEXTSETTINGS 1 shows the settings first (once).
pub(in crate::app) fn start_pdf_shx_text(&mut self, i: usize) {
let mut settings = shx::settings();
self.command_line.push_info(INTRO);
if settings.show_settings {
settings.show_settings = false;
shx::set_settings(settings);
self.save_config();
self.tabs[i].scene.deselect_all();
self.open_pdf_shx_settings(i);
return;
}
let preselected = self.tabs[i].scene.selected_handles_in_order();
if !preselected.is_empty() {
self.command_line
.push_output(&format!("{} found", preselected.len()));
self.tabs[i].scene.deselect_all();
self.convert_pdf_shx_text(i, preselected);
return;
}
self.start_pdf_shx_gather(i);
}
/// The selection prompt (also where the settings return to).
pub(in crate::app) fn start_pdf_shx_gather(&mut self, i: usize) {
use crate::command::CadCommand;
use crate::modules::draw::select::SelectObjectsCommand;
self.tabs[i].scene.deselect_all();
let command = SelectObjectsCommand::with_settings(
"PDFSHXTEXT",
APPLY,
"Select objects or [SEttings]:",
|| crate::command::CmdResult::Dispatch(OPEN_SETTINGS.to_string()),
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
/// The SEttings option: the dialog, or its command-line form with FILEDIA 0.
pub(in crate::app) fn open_pdf_shx_settings(&mut self, i: usize) {
if crate::sys::file_dia() {
let sample = crate::tr!("pdf-shx", "sample");
self.pdf_shx_dialog = Some(ShxSettingsState::new(
shx::settings(),
self.drawing_path(i),
sample,
));
self.active_modal = Some(crate::app::ModalKind::PdfShxSettings);
} else {
self.start_pdf_shx_settings_prompt(i);
}
}
pub(in crate::app) fn start_pdf_shx_settings_prompt(&mut self, i: usize) {
use crate::command::CadCommand;
let command = ShxSettingsCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
pub(in crate::app) fn update_pdf_shx_dialog(&mut self, message: ShxDialogMsg) -> Task<Message> {
let Some(state) = self.pdf_shx_dialog.as_mut() else {
return Task::none();
};
if !matches!(message, ShxDialogMsg::DragDrop(_) | ShxDialogMsg::DragStart(_)) {
state.dragging = None;
}
let count = state.settings.fonts.len();
match message {
ShxDialogMsg::Select(index) => {
state.selected = index.min(count.saturating_sub(1));
state.refresh_preview();
}
ShxDialogMsg::Toggle(index, on) => {
if let Some(font) = state.settings.fonts.get_mut(index) {
font.enabled = on;
}
state.selected = index.min(count.saturating_sub(1));
state.refresh_preview();
}
ShxDialogMsg::Up => {
let from = state.selected;
state.move_font(from, from.saturating_sub(1));
}
ShxDialogMsg::Down => {
let from = state.selected;
state.move_font(from, (from + 1).min(count.saturating_sub(1)));
}
ShxDialogMsg::Remove => {
if count > 1 && state.selected < count {
state.settings.fonts.remove(state.selected);
state.selected = state.selected.min(count - 2);
state.refresh_preview();
}
}
ShxDialogMsg::Add => {
return Task::perform(
async {
crate::sys::file_dialog()
.set_title(crate::tr!("pdf-shx", "add-title"))
.add_filter(crate::tr!("pdf-shx", "shx-files"), &["shx", "SHX"])
.pick_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
|path| Message::PdfShxDialog(ShxDialogMsg::Added(path)),
);
}
ShxDialogMsg::Added(path) => {
let Some(path) = path else {
return Task::none();
};
let full = path.to_string_lossy().into_owned();
let label = shx::font_label(&full);
match state
.settings
.fonts
.iter()
.position(|f| f.label().eq_ignore_ascii_case(&label))
{
// A listed font is turned on and chosen rather than added twice.
Some(index) => {
state.settings.fonts[index].enabled = true;
state.selected = index;
}
None => {
let builtin = shx::FONT_NAMES.iter().any(|n| n.eq_ignore_ascii_case(&label));
state.settings.fonts.push(shx::ShxFontEntry {
name: if builtin { label } else { full },
enabled: true,
});
state.selected = state.settings.fonts.len() - 1;
}
}
state.refresh_preview();
}
ShxDialogMsg::DragStart(index) => state.dragging = Some(index),
ShxDialogMsg::DragDrop(index) => {
if let Some(from) = state.dragging.take() {
state.move_font(from, index);
state.refresh_preview();
}
}
ShxDialogMsg::Threshold(value) => state.set_threshold(value),
ShxDialogMsg::ThresholdText(text) => {
// A value outside 1-100 or not a number is ignored, the box
// keeps what was typed until it is fixed.
if let Some(v) = text.trim().parse::<u8>().ok().filter(|v| (1..=100).contains(v)) {
state.settings.threshold = v;
}
state.threshold_text = text;
}
ShxDialogMsg::Step(dir) => {
let value = (i16::from(state.settings.threshold) + i16::from(dir)).clamp(1, 100);
state.set_threshold(value as u8);
}
ShxDialogMsg::CurrentLayer(on) => state.settings.current_layer = on,
ShxDialogMsg::BestFont(on) => state.settings.best_font = on,
ShxDialogMsg::Sample(text) => {
state.sample = text;
state.refresh_preview();
}
ShxDialogMsg::Defaults => {
let show = state.settings.show_settings;
state.settings = shx::ShxTextSettings {
show_settings: show,
..Default::default()
};
state.selected = 0;
state.threshold_text = state.settings.threshold.to_string();
state.refresh_preview();
}
ShxDialogMsg::Help => {
self.command_line
.push_info(&crate::tr!("pdf-shx", "help"));
}
ShxDialogMsg::Apply => {
let Some(state) = self.pdf_shx_dialog.take() else {
return Task::none();
};
shx::set_settings(state.settings);
self.save_config();
self.close_active_modal();
let i = self.active_tab;
self.start_pdf_shx_gather(i);
return self.focus_cmd_input();
}
}
Task::none()
}
/// Recognise the text in `handles` and replace it with MTEXT.
pub(in crate::app) fn convert_pdf_shx_text(&mut self, i: usize, handles: Vec<codec::Handle>) {
let document = &self.tabs[i].scene.document;
let mut strokes = Vec::new();
for handle in handles {
let Some(entity) = document.get_entity(handle) else {
continue;
};
let layer = entity.common().layer.clone();
for points in shx::entity_strokes(entity) {
if !points.is_empty() {
strokes.push(Stroke {
handle,
layer: layer.clone(),
points,
});
}
}
}
let settings = shx::settings();
let drawing = self.drawing_path(i);
self.command_line.push_output("Converting geometry to text...");
let result = shx::recognize(&strokes, &settings, |name| {
shx::resolve_font(name, drawing.as_deref())
});
for (n, group) in result.groups.iter().enumerate() {
self.command_line.push_output(&if group.converted {
format!("Group {}: Succeeded - {} {}%", n + 1, group.font, group.percent)
} else {
format!(
"Group {}: Failed to meet success threshold - {} {}%",
n + 1,
group.font,
group.percent
)
});
}
let converted = result.groups.iter().filter(|g| g.converted).count();
self.command_line.push_output(&format!(
"{converted} of {} groups converted to text",
result.groups.len()
));
self.command_line
.push_output(&format!("{} text objects created", result.lines.len()));
if result.lines.is_empty() {
return;
}
self.push_undo_snapshot(i, "PDFSHXTEXT");
let current_layer = {
let header = &self.tabs[i].scene.document.header;
if header.current_layer_name.is_empty() {
self.tabs[i].active_layer.clone()
} else {
header.current_layer_name.clone()
}
};
let scene = &mut self.tabs[i].scene;
let mut erase: Vec<codec::Handle> = Vec::new();
for line in result.lines {
// A style named after the font, made when the drawing lacks it.
if !scene.document.text_styles.contains(&line.font) {
let mut style = codec::tables::TextStyle::new(line.font.as_str());
style.handle = scene.document.allocate_handle();
style.font_file = if line.font_spec.to_ascii_lowercase().ends_with(".shx") {
line.font_spec.clone()
} else {
format!("{}.shx", line.font)
};
let _ = scene.document.text_styles.add(style);
}
let mut mtext = codec::entities::MText::new();
mtext.value = line.value;
mtext.insertion_point =
codec::types::Vector3::new(line.insertion[0], line.insertion[1], 0.0);
mtext.height = line.height;
mtext.rectangle_width = 0.0;
mtext.attachment_point = codec::entities::AttachmentPoint::BottomLeft;
mtext.style = line.font;
mtext.common.layer = if settings.current_layer {
current_layer.clone()
} else {
line.layer
};
scene.add_entity(codec::EntityType::MText(mtext));
erase.extend(line.handles);
}
erase.sort();
erase.dedup();
scene.erase_entities(&erase);
self.tabs[i].dirty = true;
self.refresh_properties();
}
/// The private commands PDFSHXTEXT runs between its prompts.
/// Whether `cmd` was one of them.
pub(in crate::app) fn dispatch_pdf_shx_text(&mut self, cmd: &str, i: usize) -> bool {
match cmd {
"PDFSHXTEXT" => self.start_pdf_shx_text(i),
APPLY => {
let handles = self.tabs[i].scene.selected_handles_in_order();
self.tabs[i].scene.deselect_all();
self.convert_pdf_shx_text(i, handles);
}
OPEN_SETTINGS => self.open_pdf_shx_settings(i),
shx::RESUME => self.start_pdf_shx_gather(i),
shx::SAVE_SETTINGS => {
self.save_config();
self.start_pdf_shx_settings_prompt(i);
}
_ => return false,
}
true
}
}
+65
View File
@@ -1225,6 +1225,9 @@ impl OpenCADStudio {
| "PDFIMPORTFILTER"
| "PDFIMPORTLAYERS"
| "PDFIMPORTIMAGEPATH"
| "PDFSHXLAYER"
| "PDFSHXBESTFONT"
| "PDFSHXTEXTSETTINGS"
| "XDWGFADECTL"
| "POINTCLOUDCLIPFRAME"
| "POINTCLOUDDENSITY"
@@ -1470,6 +1473,68 @@ impl OpenCADStudio {
}
return Some(self.finish_dispatch(cmd));
}
// PDFSHXTEXT's profile switches, taken as 1 / 0 or ON / OFF;
// PDFSHXTEXTSETTINGS shows its value as a number.
if matches!(
name.as_str(),
"PDFSHXLAYER" | "PDFSHXBESTFONT" | "PDFSHXTEXTSETTINGS"
) {
use crate::modules::insert::pdf_shx_text::{set_settings, settings};
let mut s = settings();
let on = match name.as_str() {
"PDFSHXLAYER" => s.current_layer,
"PDFSHXBESTFONT" => s.best_font,
_ => s.show_settings,
};
let current = if name == "PDFSHXTEXTSETTINGS" {
i16::from(on).to_string()
} else if on {
"ON".to_string()
} else {
"OFF".to_string()
};
let parsed = value.as_deref().map(|v| {
match v
.trim()
.trim_start_matches('_')
.to_ascii_uppercase()
.as_str()
{
"1" | "ON" => Some(true),
"0" | "OFF" => Some(false),
_ => None,
}
});
match parsed {
Some(Some(v)) => {
if v != on {
match name.as_str() {
"PDFSHXLAYER" => s.current_layer = v,
"PDFSHXBESTFONT" => s.best_font = v,
_ => s.show_settings = v,
}
set_settings(s);
self.save_config();
}
}
Some(None) => {
self.command_line.push_error(
crate::t!("Requires 0 or 1 only, or option keyword.").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 == "LAYOUTCREATEVIEWPORT" {
// A profile switch shown and taken as ON / OFF or 1 / 0.
let current = if crate::scene::layout_create_viewport() {
+67
View File
@@ -111,6 +111,7 @@ pub(super) const NAMES: &[&str] = &[
"import_file_dialog",
"dgn_import_dialog",
"refedit_dialog",
"pdf_shx_settings",
"pdf_layer_toggle",
"pc_manager_toggle",
"pc_manager",
@@ -2310,6 +2311,72 @@ impl OpenCADStudio {
_ => return Err(failure("invalid_value", "Unknown setting")),
}
}
// The PDF Text Recognition Settings dialog, one edit per call:
// "select=<n>", "toggle=<n>,0|1", "up", "down", "remove",
// "add=<path to .shx>", "move=<from>,<to>" (dragging a row),
// "threshold=<1-100>", "threshold_text=<typed>", "step=+1|-1",
// "layer=current|geometry", "best=0|1", "sample=<text>",
// "defaults", "apply" or "cancel" (rows count from 0).
"pdf_shx_settings" => {
use crate::ui::window::pdf_shx_dialog::ShxDialogMsg as S;
if self.active_modal != Some(crate::app::ModalKind::PdfShxSettings) {
return Err(failure(
"no_dialog",
"The PDF Text Recognition Settings dialog is not open",
));
}
let spec = string(req, "value")?;
let (key, value) = spec.split_once('=').unwrap_or((spec, ""));
let bad = || failure("invalid_value", spec);
let index = |v: &str| v.trim().parse::<usize>().map_err(|_| bad());
let flag = |v: &str| match v.trim() {
"1" => Ok(true),
"0" => Ok(false),
_ => Err(bad()),
};
match key {
"cancel" => Message::CloseModal,
"move" => {
let (from, to) = value.split_once(',').ok_or_else(bad)?;
let (from, to) = (index(from)?, index(to)?);
return Ok(Task::batch([
self.update(Message::PdfShxDialog(S::DragStart(from))),
self.update(Message::PdfShxDialog(S::DragDrop(to))),
]));
}
_ => Message::PdfShxDialog(match key {
"apply" => S::Apply,
"defaults" => S::Defaults,
"up" => S::Up,
"down" => S::Down,
"remove" => S::Remove,
"select" => S::Select(index(value)?),
"toggle" => {
let (row, on) = value.split_once(',').ok_or_else(bad)?;
S::Toggle(index(row)?, flag(on)?)
}
"add" => S::Added(Some(std::path::PathBuf::from(value.trim()))),
"threshold" => S::Threshold(
value
.trim()
.parse::<u8>()
.ok()
.filter(|v| (1..=100).contains(v))
.ok_or_else(bad)?,
),
"threshold_text" => S::ThresholdText(value.to_string()),
"step" => S::Step(value.trim().parse::<i8>().map_err(|_| bad())?),
"layer" => match value.trim() {
"current" => S::CurrentLayer(true),
"geometry" => S::CurrentLayer(false),
_ => return Err(bad()),
},
"best" => S::BestFont(flag(value)?),
"sample" => S::Sample(value.to_string()),
_ => return Err(bad()),
}),
}
}
"pdf_dialog_ok" => {
use crate::ui::window::pdf_dialogs::PdfDialogMsg;
Message::PdfDialog(match self.active_modal {
+4
View File
@@ -610,6 +610,7 @@ pub(super) struct OpenCADStudio {
image_adjust: Option<crate::ui::window::pdf_dialogs::ImageAdjustState>,
/// The Edit Reference dialog (REFEDIT).
refedit_dialog: Option<crate::ui::window::refedit_dialog::RefEditDialogState>,
pdf_shx_dialog: Option<crate::ui::window::pdf_shx_dialog::ShxSettingsState>,
underlay_layers: Option<crate::ui::window::pdf_dialogs::UnderlayLayersState>,
pdf_import_settings: Option<crate::modules::insert::pdf_import::PdfImportSettings>,
pdf_import_file: Option<crate::ui::window::pdf_dialogs::PdfImportFileState>,
@@ -2212,6 +2213,7 @@ pub enum ModalKind {
ViewBackground,
ImageAdjust,
RefEdit,
PdfShxSettings,
UnderlayLayers,
PdfImportSettings,
/// TXT2MTXT's Text to MText Settings, over the running command.
@@ -4454,6 +4456,7 @@ pub enum Message {
FieldDialog(crate::ui::window::field_dialog::FieldDialogMsg),
/// An edit in the Edit Reference dialog.
RefEditDialog(crate::ui::window::refedit_dialog::RefEditDialogMsg),
PdfShxDialog(crate::ui::window::pdf_shx_dialog::ShxDialogMsg),
/// A click or search in the Point Cloud Manager.
PcManager(crate::ui::window::pc_manager::PcManagerMsg),
Count(crate::ui::window::count_palette::CountMsg),
@@ -4676,6 +4679,7 @@ impl OpenCADStudio {
view_background: None,
image_adjust: None,
refedit_dialog: None,
pdf_shx_dialog: None,
underlay_layers: None,
pdf_import_settings: None,
pdf_import_file: None,
+4
View File
@@ -516,6 +516,9 @@ pub struct UserSettings {
/// LAYOUTCREATEVIEWPORT: a layout opened for the first time gets a
/// viewport.
pub layout_create_viewport: bool,
/// PDFSHXTEXT's recognition settings (fonts, threshold, PDFSHXLAYER,
/// PDFSHXBESTFONT, PDFSHXTEXTSETTINGS).
pub pdf_shx_text: crate::modules::insert::pdf_shx_text::ShxTextSettings,
/// Nested-copy symbol handling: false inserts, true binds.
pub ncopy_bind: bool,
/// Last Options page; unknown saved names fall back without rejecting the config.
@@ -992,6 +995,7 @@ impl Default for UserSettings {
ucs_ortho: true,
ucs_view: true,
layout_create_viewport: true,
pdf_shx_text: Default::default(),
ncopy_bind: false,
cursor_type: CursorType::Crosshair,
crosshair_color: None,
+2
View File
@@ -863,6 +863,7 @@ impl OpenCADStudio {
ucs_ortho: crate::modules::view::view_cmd::ucs_ortho(),
ucs_view: crate::modules::view::view_cmd::ucs_view(),
layout_create_viewport: crate::scene::layout_create_viewport(),
pdf_shx_text: crate::modules::insert::pdf_shx_text::settings(),
ncopy_bind: self.ncopy_bind,
cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color,
@@ -1010,6 +1011,7 @@ impl OpenCADStudio {
crate::modules::view::view_cmd::set_ucs_ortho(s.ucs_ortho);
crate::modules::view::view_cmd::set_ucs_view(s.ucs_view);
crate::scene::set_layout_create_viewport(s.layout_create_viewport);
crate::modules::insert::pdf_shx_text::set_settings(s.pdf_shx_text.clone());
self.ncopy_bind = s.ncopy_bind;
self.cursor_type = s.cursor_type;
self.crosshair_color = s.crosshair_color;
+9
View File
@@ -2461,6 +2461,7 @@ impl OpenCADStudio {
Message::AttMan(message) => self.on_attman(message),
Message::FieldDialog(message) => self.on_field_dialog(message),
Message::RefEditDialog(message) => self.update_refedit_dialog(message),
Message::PdfShxDialog(message) => self.update_pdf_shx_dialog(message),
Message::PcManager(message) => self.update_pc_manager(message),
Message::Count(message) => self.on_count(message),
Message::SheetSet(message) => self.on_sheet_set(message),
@@ -9710,6 +9711,14 @@ impl OpenCADStudio {
if self.active_modal == Some(super::ModalKind::RefEdit) {
self.refedit_dialog = None;
}
// Cancel returns to PDFSHXTEXT's selection prompt.
if self.active_modal == Some(super::ModalKind::PdfShxSettings) {
self.pdf_shx_dialog = None;
self.close_active_modal();
let i = self.active_tab;
self.start_pdf_shx_gather(i);
return self.focus_cmd_input();
}
// Cancelling the section settings drops the waiting extraction.
if self.active_modal == Some(super::ModalKind::PcSection) {
crate::modules::insert::pc_extract::drop_job();
+7
View File
@@ -44,6 +44,7 @@ impl OpenCADStudio {
),
Some(K::ImageAdjust) => crate::tr!("image-adjust", "title"),
Some(K::RefEdit) => crate::tr!("ref-edit", "dialog-title"),
Some(K::PdfShxSettings) => crate::tr!("pdf-shx", "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"),
@@ -569,6 +570,12 @@ impl OpenCADStudio {
crate::ui::window::pdf_dialogs::image_adjust(state, flow)
})
}
super::super::ModalKind::PdfShxSettings => {
let state = self.pdf_shx_dialog.as_ref()?;
sized_flow(ex, 780, 600, |flow| {
crate::ui::window::pdf_shx_dialog::view(state, flow)
})
}
super::super::ModalKind::RefEdit => {
let state = self.refedit_dialog.as_ref()?;
sized_flow(ex, 560, 470, |flow| {