diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index e241beee5..c8ff836ab 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6596,3 +6596,24 @@ 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. + +pdf-shx = + .title = PDF Text Recognition Settings + .preview = Preview + .sample = Sample text 1234 + .fonts = SHX fonts to compare + .enabled-count = { $on } / { $all } on + .fonts-hint = The font at the top is tried first; drag a row or move it with the arrows. + .add-font = + Add font... + .add-title = Add SHX Font + .shx-files = SHX fonts + .layer = Layer of the created text + .current-layer = Current layer + .geometry-layer = Layer of the geometry + .recognition = Recognition + .threshold = Recognition threshold + .best-font = Use best matching font + .hint = Turn on only the fonts that look like the geometry; groups below the threshold are not converted. + .defaults = Restore defaults + .apply = Apply + .help = PDFSHXTEXT compares the selected geometry with the fonts that are on, top first, and converts a group with the first font whose recognised share reaches the threshold (with "Use best matching font", with the best one). diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2733014ca..d41f364c1 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6518,3 +6518,24 @@ 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. + +pdf-shx = + .title = PDF Metin Tanıma Ayarları + .preview = Önizleme + .sample = Örnek metin 1234 + .fonts = Karşılaştırılacak SHX yazı tipleri + .enabled-count = { $on } / { $all } etkin + .fonts-hint = Üstteki yazı tipi önce denenir; sürükleyin veya oklarla taşıyın. + .add-font = + Yazı tipi ekle... + .add-title = SHX Yazı Tipi Ekle + .shx-files = SHX yazı tipleri + .layer = Oluşan metnin katmanı + .current-layer = Geçerli katman + .geometry-layer = Geometrinin katmanı + .recognition = Tanıma + .threshold = Başarı eşiği + .best-font = En iyi eşleşen yazı tipini kullan + .hint = Yalnız geometriye benzeyen yazı tiplerini açık bırakın; eşiğin altında kalan gruplar dönüştürülmez. + .defaults = Varsayılana dön + .apply = Uygula + .help = PDFSHXTEXT seçilen geometriyi açık yazı tipleriyle, en üsttekinden başlayarak karşılaştırır; bir grubu, tanınan oranı eşiğe ulaşan ilk yazı tipiyle ("En iyi eşleşen yazı tipini kullan" açıkken en iyisiyle) dönüştürür. diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index e59fdbb38..055f8671d 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -690,6 +690,7 @@ impl OpenCADStudio { self.open_blocks_palette(Some(tab)); } + cmd if self.dispatch_pdf_shx_text(cmd, i) => {} "PDFIMPORT" => { use crate::command::CadCommand; let command = crate::modules::insert::pdf_import::PdfImportCommand::new(); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1b98f6377..ce196dd72 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -22,6 +22,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; @@ -775,6 +776,9 @@ inventory::submit!(crate::command::CommandRegistration { "PDFIMPORTFILTER", "PDFIMPORTLAYERS", "PDFIMPORTIMAGEPATH", + "PDFSHXLAYER", + "PDFSHXBESTFONT", + "PDFSHXTEXTSETTINGS", "XDWGFADECTL", "POINTCLOUDCLIPFRAME", "POINTCLOUDDENSITY", diff --git a/src/app/commands/pdf_shx_text.rs b/src/app/commands/pdf_shx_text.rs new file mode 100644 index 000000000..a46d9f9b2 --- /dev/null +++ b/src/app/commands/pdf_shx_text.rs @@ -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 { + 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]:", + OPEN_SETTINGS, + ); + 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 { + 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::().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) { + 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 = 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 + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 42d3d8d9c..df7d06195 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1224,6 +1224,9 @@ impl OpenCADStudio { | "PDFIMPORTFILTER" | "PDFIMPORTLAYERS" | "PDFIMPORTIMAGEPATH" + | "PDFSHXLAYER" + | "PDFSHXBESTFONT" + | "PDFSHXTEXTSETTINGS" | "XDWGFADECTL" | "POINTCLOUDCLIPFRAME" | "POINTCLOUDDENSITY" @@ -1465,6 +1468,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() { diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index aa397d1f4..3490f03e6 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -106,6 +106,7 @@ pub(super) const NAMES: &[&str] = &[ "view_background", "image_adjust", "refedit_dialog", + "pdf_shx_settings", "pdf_layer_toggle", "pc_manager_toggle", "pc_manager", @@ -2088,6 +2089,72 @@ impl OpenCADStudio { _ => return Err(bad()), } } + // The PDF Text Recognition Settings dialog, one edit per call: + // "select=", "toggle=,0|1", "up", "down", "remove", + // "add=", "move=," (dragging a row), + // "threshold=<1-100>", "threshold_text=", "step=+1|-1", + // "layer=current|geometry", "best=0|1", "sample=", + // "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::().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::() + .ok() + .filter(|v| (1..=100).contains(v)) + .ok_or_else(bad)?, + ), + "threshold_text" => S::ThresholdText(value.to_string()), + "step" => S::Step(value.trim().parse::().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 { diff --git a/src/app/mod.rs b/src/app/mod.rs index 8e3bebe25..f6a7a289f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -595,6 +595,7 @@ pub(super) struct OpenCADStudio { image_adjust: Option, /// The Edit Reference dialog (REFEDIT). refedit_dialog: Option, + pdf_shx_dialog: Option, underlay_layers: Option, pdf_import_settings: Option, pdf_import_file: Option, @@ -2176,6 +2177,7 @@ pub enum ModalKind { ViewBackground, ImageAdjust, RefEdit, + PdfShxSettings, UnderlayLayers, PdfImportSettings, PdfImportFile, @@ -4398,6 +4400,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), @@ -4614,6 +4617,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, diff --git a/src/app/settings.rs b/src/app/settings.rs index 2726411a4..605b40c22 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -457,6 +457,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. @@ -928,6 +931,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, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c04a26005..f8f4e669f 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -825,6 +825,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, @@ -969,6 +970,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; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bcfb76142..6a737bbd4 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2409,6 +2409,7 @@ impl OpenCADStudio { Message::AttdefDialog(message) => self.on_attdef_dialog(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), @@ -9587,6 +9588,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(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index dfb36033d..f0cf1927b 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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::PdfImportFile) => crate::t!("Import PDF").into_owned(), @@ -557,6 +558,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| { diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 76417cfb5..f0dbe13bd 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 command-specific SEttings option: the private command it runs. + settings_cmd: Option<&'static str>, } impl SelectObjectsCommand { @@ -61,6 +63,7 @@ impl SelectObjectsCommand { prompt_text: None, associative_dimensions_only: false, keep: None, + settings_cmd: None, } } @@ -79,6 +82,7 @@ impl SelectObjectsCommand { prompt_text: None, associative_dimensions_only: false, keep: None, + settings_cmd: None, } } @@ -97,6 +101,7 @@ impl SelectObjectsCommand { prompt_text: None, associative_dimensions_only: false, keep: None, + settings_cmd: None, } } @@ -115,6 +120,7 @@ impl SelectObjectsCommand { prompt_text: None, associative_dimensions_only: false, keep: None, + settings_cmd: None, } } @@ -150,6 +156,19 @@ impl SelectObjectsCommand { command } + /// The command's own wording with a SEttings option that runs + /// `settings_cmd` (which brings the gather back when it is done). + pub fn with_settings( + prompt_cmd: &str, + pending_cmd: &str, + prompt_text: &'static str, + settings_cmd: &'static str, + ) -> Self { + let mut command = Self::with_prompt(prompt_cmd, pending_cmd, prompt_text); + command.settings_cmd = Some(settings_cmd); + 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_cmd.is_some() { + return vec![CmdOption::new(t!("SEttings").as_ref(), "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 token = text.trim().trim_start_matches('_').to_uppercase(); + if let Some(cmd) = self.settings_cmd { + if token.len() >= 2 && "SETTINGS".starts_with(token.as_str()) { + return Some(CmdResult::Dispatch(cmd.to_string())); + } + } match text.trim().to_uppercase().as_str() { "S" | "SETTINGS" if self.auto_constrain_settings => { Some(CmdResult::OpenAutoConstrainSettings) diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index a8677fe3c..4278fd8a2 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -22,6 +22,7 @@ pub(crate) mod pc_stylize; pub(crate) mod pdf_attach; pub(crate) mod pdf_clip; pub(crate) mod pdf_import; +pub(crate) mod pdf_shx_text; pub(crate) mod picker; mod snap_underlays; pub(crate) mod solid3d_cmds; diff --git a/src/modules/insert/pdf_shx_text.rs b/src/modules/insert/pdf_shx_text.rs new file mode 100644 index 000000000..93582a01e --- /dev/null +++ b/src/modules/insert/pdf_shx_text.rs @@ -0,0 +1,1178 @@ +// PDFSHXTEXT — recognise text that was drawn as SHX stroke geometry (a PDF +// plotted with SHX fonts and imported back) and replace it with MTEXT. +// +// Select geometry to convert to text... +// Select objects or [SEttings]: +// Converting geometry to text... +// Group 1: Succeeded - simplex 100% +// Group 2: Failed to meet success threshold - simplex 64% +// 1 of 2 groups converted to text +// 2 text objects created +// +// The strokes are clustered into characters, the characters into text lines +// and the lines into groups (lines of one size stacked together). Each line +// is matched against the glyphs of the fonts to compare, in list order: a +// group converts with the first font (or, with "Use best matching font", the +// best) whose share of recognised characters reaches the threshold. Every +// recognised line becomes a bottom-left MTEXT in a style named after the font; +// the geometry of its recognised characters is erased. + +use codec::Handle; +use glam::DVec3; +use serde::{Deserialize, Serialize}; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::scene::text::font_face::Face; + +/// The fonts the reference lists, in its order. +pub const FONT_NAMES: [&str; 8] = [ + "simplex", "romans", "txt", "romand", "romanc", "complex", "romant", "isocp", +]; +/// Enabled by default. +const DEFAULT_ENABLED: [&str; 3] = ["simplex", "txt", "isocp"]; + +/// One font of the comparison list. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ShxFontEntry { + /// The font name ("simplex"), or the full path of an added .shx file. + pub name: String, + pub enabled: bool, +} + +impl ShxFontEntry { + /// The name shown and given to the text style: the file stem. + pub fn label(&self) -> String { + font_label(&self.name) + } +} + +pub fn font_label(name: &str) -> String { + std::path::Path::new(&name.replace('\\', "/")) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| name.to_string()) +} + +/// The PDF Text Recognition Settings (profile settings). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ShxTextSettings { + pub fonts: Vec, + /// Recognition threshold in percent (1–100). + pub threshold: u8, + /// PDFSHXLAYER: the text goes on the current layer (else the geometry's). + pub current_layer: bool, + /// PDFSHXBESTFONT: compare every enabled font and keep the best. + pub best_font: bool, + /// PDFSHXTEXTSETTINGS: the next PDFSHXTEXT opens the settings first. + pub show_settings: bool, +} + +impl Default for ShxTextSettings { + fn default() -> Self { + Self { + fonts: FONT_NAMES + .iter() + .map(|name| ShxFontEntry { + name: name.to_string(), + enabled: DEFAULT_ENABLED.contains(name), + }) + .collect(), + threshold: 95, + current_layer: true, + best_font: false, + show_settings: false, + } + } +} + +impl ShxTextSettings { + /// Out-of-range values from a hand-edited profile. + pub fn sanitize(&mut self) { + self.threshold = self.threshold.clamp(1, 100); + self.fonts.retain(|f| !f.name.trim().is_empty()); + let mut seen = std::collections::HashSet::new(); + self.fonts + .retain(|f| seen.insert(f.label().to_ascii_lowercase())); + if self.fonts.is_empty() { + self.fonts = Self::default().fonts; + } + } +} + +static SETTINGS: std::sync::Mutex> = std::sync::Mutex::new(None); + +pub fn settings() -> ShxTextSettings { + SETTINGS + .lock() + .ok() + .and_then(|s| s.clone()) + .unwrap_or_default() +} + +pub fn set_settings(mut settings: ShxTextSettings) { + settings.sanitize(); + if let Ok(mut s) = SETTINGS.lock() { + *s = Some(settings); + } +} + +/// What `Face::resolve` loads for a listed font: an added file as it is; a +/// font name's .shx next to the drawing (`drawing` is its path) or in the +/// fonts folder; else the name, which the built-in stroke fonts answer. +pub fn resolve_font(name: &str, drawing: Option<&str>) -> String { + if name.to_ascii_lowercase().ends_with(".shx") && std::path::Path::new(name).is_file() { + return name.to_string(); + } + let file = format!("{}.shx", font_label(name)); + let base = drawing.map(std::path::Path::new).and_then(|p| p.parent()); + crate::io::resolve_image_file(&file, base) + .or_else(|| { + crate::io::font_repo::local_font_file(&file).map(|p| p.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| font_label(name)) +} + +// ── Recognition ──────────────────────────────────────────────────────────── + +/// One curve of the selection, flattened to world XY. +pub struct Stroke { + pub handle: Handle, + pub layer: String, + pub points: Vec<[f64; 2]>, +} + +/// A recognised line of text. +pub struct TextLine { + /// MTEXT contents (with a `\W` width factor when it is not 1). + pub value: String, + /// Bottom-left corner of the recognised ink. + pub insertion: [f64; 2], + pub height: f64, + /// The font label, also the text style's name. + pub font: String, + /// The font as listed (a name or the path of an added file). + pub font_spec: String, + /// The layer of the line's first stroke. + pub layer: String, + /// The strokes the line replaces. + pub handles: Vec, +} + +pub struct GroupReport { + pub font: String, + pub percent: u32, + pub converted: bool, +} + +pub struct Recognition { + pub groups: Vec, + pub lines: Vec, +} + +type P = [f64; 2]; + +#[derive(Clone, Copy)] +struct BBox { + min: P, + max: P, +} + +impl BBox { + fn empty() -> Self { + Self { + min: [f64::INFINITY; 2], + max: [f64::NEG_INFINITY; 2], + } + } + fn of(points: &[P]) -> Self { + let mut b = Self::empty(); + for p in points { + b.add(*p); + } + b + } + fn add(&mut self, p: P) { + self.min = [self.min[0].min(p[0]), self.min[1].min(p[1])]; + self.max = [self.max[0].max(p[0]), self.max[1].max(p[1])]; + } + fn union(&mut self, o: &BBox) { + self.add(o.min); + self.add(o.max); + } + fn w(&self) -> f64 { + self.max[0] - self.min[0] + } + fn h(&self) -> f64 { + self.max[1] - self.min[1] + } + fn size(&self) -> f64 { + self.w().max(self.h()) + } + fn cx(&self) -> f64 { + (self.min[0] + self.max[0]) * 0.5 + } + fn near(&self, o: &BBox, eps: f64) -> bool { + self.min[0] <= o.max[0] + eps + && o.min[0] <= self.max[0] + eps + && self.min[1] <= o.max[1] + eps + && o.min[1] <= self.max[1] + eps + } +} + +fn seg_dist(p: P, a: P, b: P) -> f64 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { + (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + let (x, y) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]); + (x * x + y * y).sqrt() +} + +fn dist_to(p: P, strokes: &[Vec

]) -> f64 { + let mut best = f64::INFINITY; + for s in strokes { + match s.len() { + 0 => {} + 1 => best = best.min(seg_dist(p, s[0], s[0])), + _ => { + for w in s.windows(2) { + best = best.min(seg_dist(p, w[0], w[1])); + } + } + } + } + best +} + +fn cross(o: P, a: P, b: P) -> f64 { + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) +} + +fn segments_cross(a: P, b: P, c: P, d: P) -> bool { + let (d1, d2) = (cross(c, d, a), cross(c, d, b)); + let (d3, d4) = (cross(a, b, c), cross(a, b, d)); + (d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0) +} + +/// Least distance between two polylines (0 where they cross). +fn polyline_dist(a: &[P], b: &[P]) -> f64 { + let segs = |s: &[P]| -> Vec<(P, P)> { + if s.len() == 1 { + vec![(s[0], s[0])] + } else { + s.windows(2).map(|w| (w[0], w[1])).collect() + } + }; + let (sa, sb) = (segs(a), segs(b)); + let mut best = f64::INFINITY; + for &(p, q) in &sa { + for &(r, t) in &sb { + if segments_cross(p, q, r, t) { + return 0.0; + } + best = best + .min(seg_dist(p, r, t)) + .min(seg_dist(q, r, t)) + .min(seg_dist(r, p, q)) + .min(seg_dist(t, p, q)); + } + } + best +} + +/// Points along the strokes no more than `step` apart (vertices included). +fn sample(strokes: &[Vec

], step: f64) -> Vec

{ + let mut out = Vec::new(); + for s in strokes { + if let Some(first) = s.first() { + out.push(*first); + } + for w in s.windows(2) { + let len = ((w[1][0] - w[0][0]).powi(2) + (w[1][1] - w[0][1]).powi(2)).sqrt(); + let n = if step > 0.0 { + (len / step).ceil().clamp(1.0, 400.0) as usize + } else { + 1 + }; + for k in 1..=n { + let t = k as f64 / n as f64; + out.push([ + w[0][0] + (w[1][0] - w[0][0]) * t, + w[0][1] + (w[1][1] - w[0][1]) * t, + ]); + } + } + } + out +} + +/// Mean distance both ways between two stroke sets. +fn chamfer(a_samples: &[P], a: &[Vec

], b_samples: &[P], b: &[Vec

]) -> f64 { + if a_samples.is_empty() || b_samples.is_empty() { + return f64::INFINITY; + } + let ab: f64 = a_samples.iter().map(|p| dist_to(*p, b)).sum::() / a_samples.len() as f64; + let ba: f64 = b_samples.iter().map(|p| dist_to(*p, a)).sum::() / b_samples.len() as f64; + 0.5 * (ab + ba) +} + +/// Scale factors that map a box to the unit square; a near-flat side takes +/// the other side's scale so a dash or a bar keeps its proportions. +fn unit_scales(b: &BBox) -> (f64, f64) { + let m = b.size(); + if m <= 0.0 { + return (1.0, 1.0); + } + let sx = if b.w() < 0.15 * m { m } else { b.w() }; + let sy = if b.h() < 0.15 * m { m } else { b.h() }; + (sx, sy) +} + +fn normalized(strokes: &[Vec

], b: &BBox) -> Vec> { + let (sx, sy) = unit_scales(b); + strokes + .iter() + .map(|s| { + s.iter() + .map(|p| [(p[0] - b.min[0]) / sx, (p[1] - b.min[1]) / sy]) + .collect() + }) + .collect() +} + +/// A glyph in units of the font's cap height. +struct GlyphShape { + ch: char, + strokes: Vec>, + bbox: BBox, + /// Pen advance including the spacing after it. + advance: f64, + norm: Vec>, + norm_samples: Vec

, +} + +struct FontShapes { + glyphs: Vec, + word: f64, +} + +/// Sampling step of the comparisons, in text heights. +const STEP: f64 = 0.04; +/// Largest mean distance (in text heights) of a recognised character. +/// The geometry of a font matches its glyphs within a few thousandths; a +/// look-alike glyph of another font (txt's O as a D) from about 0.015. +const MATCH_TOLERANCE: f64 = 0.012; + +fn font_shapes(spec: &str) -> Option { + let face = Face::resolve(spec); + let to_p = |s: &Vec<[f32; 2]>| -> Vec

{ s.iter().map(|p| [p[0] as f64, p[1] as f64]).collect() }; + let cap = face + .glyph('H') + .map(|g| { + let pts: Vec

= g.strokes.iter().flat_map(to_p).collect(); + BBox::of(&pts).h() + }) + .filter(|h| *h > 0.0)?; + let mut glyphs = Vec::new(); + // ponytail: printable ASCII only; symbols (°, ±, Ø, Greek) left as geometry. + for code in 0x21u8..=0x7e { + let ch = code as char; + let Some(g) = face.glyph(ch) else { continue }; + let strokes: Vec> = g + .strokes + .iter() + .filter(|s| !s.is_empty()) + .map(|s| s.iter().map(|p| [p[0] as f64 / cap, p[1] as f64 / cap]).collect()) + .collect(); + if strokes.is_empty() { + continue; + } + let bbox = BBox::of(&strokes.iter().flatten().copied().collect::>()); + let norm = normalized(&strokes, &bbox); + let norm_samples = sample(&norm, STEP); + glyphs.push(GlyphShape { + ch, + advance: (g.advance + face.spacing_after(ch)) as f64 / cap, + strokes, + bbox, + norm, + norm_samples, + }); + } + Some(FontShapes { + glyphs, + word: face.word_spacing() as f64 / cap, + }) +} + +/// Strokes that touch or cross, one character (or part of one). +struct Piece { + strokes: Vec, + bbox: BBox, +} + +/// A character: pieces that share a column of a line. +struct Glyphed { + strokes: Vec, + bbox: BBox, + geometry: Vec>, +} + +struct Line { + chars: Vec, + bbox: BBox, + first: usize, + height: f64, +} + +fn find(parent: &mut [usize], mut x: usize) -> usize { + while parent[x] != x { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + x +} + +fn union(parent: &mut [usize], a: usize, b: usize) { + let (ra, rb) = (find(parent, a), find(parent, b)); + if ra != rb { + parent[ra.max(rb)] = ra.min(rb); + } +} + +/// Pairs of boxes (by index) whose x-ranges, widened by `pad(i)`, overlap. +fn x_sweep(boxes: &[BBox], pad: impl Fn(usize) -> f64, mut visit: impl FnMut(usize, usize)) { + let mut order: Vec = (0..boxes.len()).collect(); + order.sort_by(|a, b| boxes[*a].min[0].total_cmp(&boxes[*b].min[0])); + for (k, &a) in order.iter().enumerate() { + let reach = boxes[a].max[0] + pad(a); + for &b in &order[k + 1..] { + if boxes[b].min[0] > reach { + break; + } + visit(a, b); + } + } +} + +fn pieces_of(strokes: &[Stroke]) -> Vec { + let boxes: Vec = strokes.iter().map(|s| BBox::of(&s.points)).collect(); + let tiny = boxes + .iter() + .map(|b| b.size()) + .fold(0.0f64, f64::max) + * 1e-9; + let eps = |a: usize, b: usize| 0.03 * boxes[a].size().min(boxes[b].size()) + tiny; + let mut parent: Vec = (0..strokes.len()).collect(); + let widest = boxes.iter().map(|b| b.size()).fold(0.0f64, f64::max); + x_sweep(&boxes, |_| 0.03 * widest + tiny, |a, b| { + let e = eps(a, b); + if !boxes[a].near(&boxes[b], e) { + return; + } + if polyline_dist(&strokes[a].points, &strokes[b].points) <= e { + union(&mut parent, a, b); + } + }); + let mut map: std::collections::BTreeMap = Default::default(); + for (i, b) in boxes.iter().enumerate() { + let root = find(&mut parent, i); + let piece = map.entry(root).or_insert_with(|| Piece { + strokes: Vec::new(), + bbox: BBox::empty(), + }); + piece.strokes.push(i); + piece.bbox.union(b); + } + map.into_values().collect() +} + +/// Pieces of comparable size side by side on one band make a line; smaller +/// pieces (dots, commas, the dot of an i) join the line of the character +/// they sit closest to. +fn lines_of(strokes: &[Stroke], pieces: Vec) -> Vec { + let boxes: Vec = pieces.iter().map(|p| p.bbox).collect(); + let comparable = |a: usize, b: usize| { + let (sa, sb) = (boxes[a].size(), boxes[b].size()); + sa.min(sb) >= 0.35 * sa.max(sb) && sa.max(sb) > 0.0 + }; + let mut parent: Vec = (0..pieces.len()).collect(); + let tallest = boxes.iter().map(|b| b.h()).fold(0.0f64, f64::max); + x_sweep(&boxes, |_| 2.2 * tallest, |a, b| { + if !comparable(a, b) { + return; + } + let (ba, bb) = (&boxes[a], &boxes[b]); + let overlap = ba.max[1].min(bb.max[1]) - ba.min[1].max(bb.min[1]); + if overlap < 0.3 * ba.h().min(bb.h()) { + return; + } + let gap = (ba.min[0].max(bb.min[0]) - ba.max[0].min(bb.max[0])).max(0.0); + if gap <= 2.2 * ba.h().max(bb.h()) { + union(&mut parent, a, b); + } + }); + // A piece with no comparable neighbour is a "small" one when something + // at least three times its size is close by. + let mut members = std::collections::HashMap::::new(); + for i in 0..pieces.len() { + *members.entry(find(&mut parent, i)).or_default() += 1; + } + let alone: Vec = (0..pieces.len()) + .map(|i| members[&find(&mut parent, i)] == 1) + .collect(); + // ponytail: every lone piece is tested against every piece (boxes first); + // a spatial grid if selections of many thousand pieces get slow. + for i in 0..pieces.len() { + if !alone[i] { + continue; + } + let si = boxes[i].size(); + let mut best: Option<(f64, usize)> = None; + for j in 0..pieces.len() { + let sj = boxes[j].size(); + if j == i || si >= 0.35 * sj || !boxes[i].near(&boxes[j], sj) { + continue; + } + let mut d = f64::INFINITY; + for m in &pieces[i].strokes { + for t in &pieces[j].strokes { + d = d.min(polyline_dist(&strokes[*m].points, &strokes[*t].points)); + } + } + if d <= 0.6 * sj && best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, j)); + } + } + if let Some((_, j)) = best { + union(&mut parent, i, j); + } + } + let mut groups: std::collections::BTreeMap> = Default::default(); + for i in 0..pieces.len() { + groups.entry(find(&mut parent, i)).or_default().push(i); + } + let mut lines = Vec::new(); + for members in groups.into_values() { + // Pieces sharing a column are one character. + let mut sorted = members; + sorted.sort_by(|a, b| boxes[*a].min[0].total_cmp(&boxes[*b].min[0])); + let mut chars: Vec = Vec::new(); + for p in sorted { + let b = boxes[p]; + let joins = chars.last().is_some_and(|c| { + let overlap = c.bbox.max[0].min(b.max[0]) - c.bbox.min[0].max(b.min[0]); + overlap >= 0.5 * c.bbox.w().min(b.w()) + || (b.cx() >= c.bbox.min[0] && b.cx() <= c.bbox.max[0]) + || (c.bbox.cx() >= b.min[0] && c.bbox.cx() <= b.max[0]) + }); + if joins { + let c = chars.last_mut().expect("checked"); + c.strokes.extend(pieces[p].strokes.iter().copied()); + c.bbox.union(&b); + } else { + chars.push(Glyphed { + strokes: pieces[p].strokes.clone(), + bbox: b, + geometry: Vec::new(), + }); + } + } + let mut bbox = BBox::empty(); + let mut first = usize::MAX; + let mut height = 0.0f64; + for c in &mut chars { + c.geometry = c.strokes.iter().map(|s| strokes[*s].points.clone()).collect(); + bbox.union(&c.bbox); + first = first.min(c.strokes.iter().copied().min().unwrap_or(usize::MAX)); + height = height.max(c.bbox.h()); + } + lines.push(Line { + chars, + bbox, + first, + height, + }); + } + lines +} + +/// Lines of one size stacked close together. +fn groups_of(lines: &[Line]) -> Vec> { + let mut parent: Vec = (0..lines.len()).collect(); + for a in 0..lines.len() { + for b in a + 1..lines.len() { + let (la, lb) = (&lines[a], &lines[b]); + let h = la.height.max(lb.height); + if h <= 0.0 || la.height.min(lb.height) < 0.85 * h { + continue; + } + let x_gap = (la.bbox.min[0].max(lb.bbox.min[0]) - la.bbox.max[0].min(lb.bbox.max[0])) + .max(0.0); + let y_gap = (la.bbox.min[1].max(lb.bbox.min[1]) - la.bbox.max[1].min(lb.bbox.max[1])) + .max(0.0); + if x_gap <= 2.0 * h && y_gap <= 3.2 * h { + union(&mut parent, a, b); + } + } + } + let mut groups: std::collections::BTreeMap> = Default::default(); + for i in 0..lines.len() { + groups.entry(find(&mut parent, i)).or_default().push(i); + } + let mut out: Vec> = groups.into_values().collect(); + // In the order the geometry was drawn. + out.sort_by_key(|g| g.iter().map(|l| lines[*l].first).min().unwrap_or(0)); + for g in &mut out { + g.sort_by_key(|l| lines[*l].first); + } + out +} + +fn median(mut v: Vec) -> Option { + if v.is_empty() { + return None; + } + v.sort_by(|a, b| a.total_cmp(b)); + Some(v[v.len() / 2]) +} + +/// Height the reference gives a line that has nothing with a height in it +/// (a lone dash), measured. +const FLAT_LINE_HEIGHT: f64 = 0.15; + +struct LineMatch { + /// Recognised glyph per character (None: not recognised). + glyphs: Vec>, + height: f64, + width_factor: f64, +} + +fn match_line(line: &Line, font: &FontShapes) -> LineMatch { + // Pass 1: shapes alone, each in its own box. A shape that fits several + // glyphs (o / O, x / X) keeps them all as candidates. + let mut first: Vec> = Vec::with_capacity(line.chars.len()); + for c in &line.chars { + let norm = normalized(&c.geometry, &c.bbox); + let samples = sample(&norm, STEP); + let mut scored: Vec<(usize, f64)> = font + .glyphs + .iter() + .enumerate() + .map(|(gi, g)| (gi, chamfer(&samples, &norm, &g.norm_samples, &g.norm))) + .collect(); + scored.sort_by(|a, b| a.1.total_cmp(&b.1)); + let best = scored.first().map_or(f64::INFINITY, |b| b.1); + scored.retain(|(_, d)| *d <= (1.3 * best + 0.01).min(0.12)); + first.push(scored); + } + // The line's scale: the one most characters agree with through one of + // their candidates (those with a height of their own). + let hyps: Vec> = line + .chars + .iter() + .zip(&first) + .map(|(c, cands)| { + cands + .iter() + .filter(|(gi, _)| font.glyphs[*gi].bbox.h() > 0.2) + .map(|(gi, _)| (*gi, c.bbox.h() / font.glyphs[*gi].bbox.h())) + .collect() + }) + .collect(); + let agree = |s: f64| -> Vec<(usize, usize)> { + hyps.iter() + .enumerate() + .filter_map(|(ci, hs)| { + hs.iter() + .filter(|(_, h)| (h / s - 1.0).abs() <= 0.06) + .min_by(|a, b| (a.1 / s - 1.0).abs().total_cmp(&(b.1 / s - 1.0).abs())) + .map(|(gi, _)| (ci, *gi)) + }) + .collect() + }; + // On a tie the taller reading wins: a lone round shape is an O at cap + // height rather than an o. + let mut support: Vec<(usize, usize)> = Vec::new(); + let mut support_scale = f64::INFINITY; + for hs in &hyps { + for (_, h) in hs { + let votes = agree(*h); + if votes.len() > support.len() + || (votes.len() == support.len() && *h < support_scale * 0.94) + { + support = votes; + support_scale = *h; + } + } + } + let height = median( + support + .iter() + .map(|(ci, gi)| line.chars[*ci].bbox.h() / font.glyphs[*gi].bbox.h()) + .collect(), + ); + let Some(height) = height.filter(|h| *h > 0.0) else { + // Nothing with a height: the reference's flat-line height, flat + // glyphs (a dash) taken as they are. Anything else here (a frame + // that looks like a scaled-up dot) is no character. + let flat = |b: &BBox| b.h() <= 0.15 * b.w(); + let glyphs = line + .chars + .iter() + .zip(&first) + .map(|(c, cands)| { + cands + .first() + .filter(|(gi, d)| { + *d <= MATCH_TOLERANCE && flat(&c.bbox) && flat(&font.glyphs[*gi].bbox) + }) + .map(|(gi, _)| *gi) + }) + .collect(); + return LineMatch { + glyphs, + height: FLAT_LINE_HEIGHT, + width_factor: 1.0, + }; + }; + let baseline = median( + support + .iter() + .map(|(ci, gi)| line.chars[*ci].bbox.min[1] - height * font.glyphs[*gi].bbox.min[1]) + .collect(), + ) + .unwrap_or(line.bbox.min[1]); + let mut width_factor = median( + support + .iter() + .filter(|(_, gi)| font.glyphs[*gi].bbox.w() > 0.2) + .map(|(ci, gi)| line.chars[*ci].bbox.w() / (height * font.glyphs[*gi].bbox.w())) + .collect(), + ) + .unwrap_or(1.0); + if (width_factor - 1.0).abs() < 0.03 { + width_factor = 1.0; + } + // Pass 2: every glyph at the line's size, on its baseline, centred on + // the character. + let step = STEP * height; + let glyphs = line + .chars + .iter() + .map(|c| { + let samples = sample(&c.geometry, step); + let mut best: Option<(usize, f64)> = None; + for (gi, g) in font.glyphs.iter().enumerate() { + let gh = g.bbox.h() * height; + let gw = g.bbox.w() * height * width_factor; + if (gh - c.bbox.h()).abs() > 0.25 * height + || (gw - c.bbox.w()).abs() > 0.25 * height + { + continue; + } + let gcx = g.bbox.cx(); + let placed: Vec> = g + .strokes + .iter() + .map(|s| { + s.iter() + .map(|p| { + [ + c.bbox.cx() + (p[0] - gcx) * height * width_factor, + baseline + p[1] * height, + ] + }) + .collect() + }) + .collect(); + let d = chamfer(&samples, &c.geometry, &sample(&placed, step), &placed) / height; + if best.is_none_or(|(_, bd)| d < bd) { + best = Some((gi, d)); + } + } + best.filter(|(_, d)| *d <= MATCH_TOLERANCE).map(|(gi, _)| gi) + }) + .collect(); + LineMatch { + glyphs, + height, + width_factor, + } +} + +fn escape_mtext(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + if matches!(ch, '\\' | '{' | '}') { + out.push('\\'); + } + out.push(ch); + } + out +} + +/// The text of a matched line: recognised characters, with as many spaces +/// as fit the pen gaps between them (an unrecognised character is a gap). +fn line_text(line: &Line, m: &LineMatch, font: &FontShapes) -> Option<(String, BBox)> { + let scale = m.height * m.width_factor; + let mut text = String::new(); + let mut ink = BBox::empty(); + let mut pen_end: Option = None; + for (c, g) in line.chars.iter().zip(&m.glyphs) { + let Some(gi) = g else { continue }; + let g = &font.glyphs[*gi]; + let origin = c.bbox.cx() - g.bbox.cx() * scale; + if let Some(end) = pen_end { + let space = font.word * scale; + if space > 0.0 { + let n = ((origin - end) / space).round(); + for _ in 0..(n.max(0.0) as usize).min(64) { + text.push(' '); + } + } + } + text.push(g.ch); + pen_end = Some(origin + g.advance * scale); + ink.union(&c.bbox); + } + if text.is_empty() { + return None; + } + let body = escape_mtext(&text); + let value = if m.width_factor != 1.0 { + format!("{{\\W{:.6};{}}}", m.width_factor, body) + } else { + body + }; + Some((value, ink)) +} + +fn percent(recognised: usize, total: usize) -> u32 { + if total == 0 { + 0 + } else { + (100.0 * recognised as f64 / total as f64).round() as u32 + } +} + +/// Recognise the text in `strokes` with the fonts of `settings`; `resolve` +/// turns a listed font into what `Face::resolve` loads. +pub fn recognize( + strokes: &[Stroke], + settings: &ShxTextSettings, + resolve: impl Fn(&str) -> String, +) -> Recognition { + let pieces = pieces_of(strokes); + let lines = lines_of(strokes, pieces); + let groups = groups_of(&lines); + let fonts: Vec<(String, FontShapes)> = settings + .fonts + .iter() + .filter(|f| f.enabled) + .filter_map(|f| font_shapes(&resolve(&f.name)).map(|shapes| (f.name.clone(), shapes))) + .collect(); + let mut out = Recognition { + groups: Vec::new(), + lines: Vec::new(), + }; + for group in groups { + let total: usize = group.iter().map(|l| lines[*l].chars.len()).sum(); + let mut best: Option<(usize, u32, Vec)> = None; + let mut chosen: Option<(usize, u32, Vec)> = None; + for (fi, (_, shapes)) in fonts.iter().enumerate() { + let matches: Vec = group.iter().map(|l| match_line(&lines[*l], shapes)).collect(); + let recognised: usize = matches + .iter() + .map(|m| m.glyphs.iter().filter(|g| g.is_some()).count()) + .sum(); + let pct = percent(recognised, total); + if pct >= u32::from(settings.threshold) && !settings.best_font { + chosen = Some((fi, pct, matches)); + break; + } + if best.as_ref().is_none_or(|(_, bp, _)| pct > *bp) { + best = Some((fi, pct, matches)); + } + } + // A lone shape no font recognises (a frame, a boundary) is not text: + // it is no group. + if chosen.is_none() && total == 1 && best.as_ref().is_none_or(|(_, pct, _)| *pct == 0) { + continue; + } + if chosen.is_none() + && best + .as_ref() + .is_some_and(|(_, pct, _)| *pct >= u32::from(settings.threshold)) + { + chosen = best.take(); + } + match chosen { + Some((fi, pct, matches)) => { + let (spec, shapes) = &fonts[fi]; + out.groups.push(GroupReport { + font: font_label(spec), + percent: pct, + converted: true, + }); + for (l, m) in group.iter().zip(&matches) { + let line = &lines[*l]; + let Some((value, ink)) = line_text(line, m, shapes) else { + continue; + }; + let handles: Vec = line + .chars + .iter() + .zip(&m.glyphs) + .filter(|(_, g)| g.is_some()) + .flat_map(|(c, _)| c.strokes.iter().map(|s| strokes[*s].handle)) + .collect(); + out.lines.push(TextLine { + value, + insertion: ink.min, + height: m.height, + font: font_label(spec), + font_spec: spec.clone(), + layer: strokes[line.first].layer.clone(), + handles, + }); + } + } + None => { + let (font, pct) = best + .map(|(fi, pct, _)| (font_label(&fonts[fi].0), pct)) + .unwrap_or_else(|| { + ( + fonts.first().map(|f| font_label(&f.0)).unwrap_or_default(), + 0, + ) + }); + out.groups.push(GroupReport { + font, + percent: pct, + converted: false, + }); + } + } + } + out +} + +/// The curves of `entity` as world-XY strokes (text and other objects: none). +pub fn entity_strokes(entity: &codec::EntityType) -> Vec> { + use codec::EntityType as E; + match entity { + E::Line(_) + | E::Arc(_) + | E::Circle(_) + | E::Ellipse(_) + | E::LwPolyline(_) + | E::Polyline2D(_) + | E::Spline(_) => crate::entities::curve::entity_curve(entity) + .map(|c| { + vec![crate::entities::curve::curve_points(&c) + .into_iter() + .map(|p| [p[0], p[1]]) + .collect()] + }) + .unwrap_or_default(), + _ => Vec::new(), + } +} + +// ── Commands ─────────────────────────────────────────────────────────────── + +/// FILEDIA 0 form of the settings, between PDFSHXTEXT's selection prompts. +pub struct ShxSettingsCommand { + step: SettingsStep, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SettingsStep { + Option, + Fonts, + Threshold, + Layer, + BestFont, +} + +impl ShxSettingsCommand { + pub fn new() -> Self { + Self { + step: SettingsStep::Option, + } + } + + fn back(&mut self) -> CmdResult { + self.step = SettingsStep::Option; + CmdResult::NeedPoint + } +} + +impl CadCommand for ShxSettingsCommand { + fn name(&self) -> &'static str { + "PDFSHXTEXT" + } + + fn prompt(&self) -> String { + let s = settings(); + match self.step { + SettingsStep::Option => { + "Enter a recognition setting [Fonts/Threshold/Layer/Bestfont] :".to_string() + } + SettingsStep::Fonts => format!( + "Enter the fonts to compare, in order <{}>:", + s.fonts + .iter() + .filter(|f| f.enabled) + .map(|f| f.label()) + .collect::>() + .join(",") + ), + SettingsStep::Threshold => { + format!("Enter recognition threshold (1-100) <{}>:", s.threshold) + } + SettingsStep::Layer => format!( + "Create text on [Current/Geometry] layer <{}>:", + if s.current_layer { "Current" } else { "Geometry" } + ), + SettingsStep::BestFont => format!( + "Use best matching font? [Yes/No] <{}>:", + if s.best_font { "Yes" } else { "No" } + ), + } + } + + fn options(&self) -> Vec { + match self.step { + SettingsStep::Option => vec![ + CmdOption::new("Fonts", "F"), + CmdOption::new("Threshold", "T"), + CmdOption::new("Layer", "L"), + CmdOption::new("Bestfont", "B"), + ], + SettingsStep::Layer => vec![ + CmdOption::new("Current", "C"), + CmdOption::new("Geometry", "G"), + ], + SettingsStep::BestFont => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + InputKind::SingleToken + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if token.is_empty() { + return Some(self.on_enter()); + } + let mut s = settings(); + let invalid = || Some(CmdResult::ReportError("Invalid option keyword.".to_string())); + match self.step { + SettingsStep::Option => { + self.step = match token.as_str() { + "F" | "FONTS" => SettingsStep::Fonts, + "T" | "THRESHOLD" => SettingsStep::Threshold, + "L" | "LAYER" => SettingsStep::Layer, + "B" | "BESTFONT" => SettingsStep::BestFont, + _ => return invalid(), + }; + return Some(CmdResult::NeedPoint); + } + SettingsStep::Fonts => { + let wanted: Vec = text + .split([',', ' ', ';']) + .map(|n| n.trim().to_ascii_lowercase()) + .filter(|n| !n.is_empty()) + .collect(); + if let Some(bad) = wanted + .iter() + .find(|n| !s.fonts.iter().any(|f| f.label().eq_ignore_ascii_case(n))) + { + return Some(CmdResult::ReportError(format!("Font \"{bad}\" is not in the list."))); + } + // The named fonts first, in the order given and enabled; the + // rest after them, off. + let mut fonts: Vec = Vec::new(); + for n in &wanted { + if let Some(f) = s.fonts.iter().find(|f| f.label().eq_ignore_ascii_case(n)) { + if !fonts.iter().any(|g| g.name == f.name) { + fonts.push(ShxFontEntry { + name: f.name.clone(), + enabled: true, + }); + } + } + } + for f in &s.fonts { + if !fonts.iter().any(|g| g.name == f.name) { + fonts.push(ShxFontEntry { + name: f.name.clone(), + enabled: false, + }); + } + } + s.fonts = fonts; + } + SettingsStep::Threshold => match token.parse::() { + Ok(v) if (1.0..=100.0).contains(&v) => s.threshold = v.round() as u8, + _ => { + return Some(CmdResult::ReportError( + "Requires an integer between 1 and 100.".to_string(), + )) + } + }, + SettingsStep::Layer => match token.as_str() { + "C" | "CURRENT" => s.current_layer = true, + "G" | "GEOMETRY" => s.current_layer = false, + _ => return invalid(), + }, + SettingsStep::BestFont => match token.as_str() { + "Y" | "YES" => s.best_font = true, + "N" | "NO" => s.best_font = false, + _ => return invalid(), + }, + } + set_settings(s); + // The host saves the profile and asks for the next setting. + Some(CmdResult::Dispatch(SAVE_SETTINGS.to_string())) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.step == SettingsStep::Option { + CmdResult::Dispatch(RESUME.to_string()) + } else { + self.back() + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +/// Private commands: back to the selection prompt after the settings, and +/// persisting a setting typed on the command line. +pub const RESUME: &str = "_PDFSHXTEXTRESUME"; +pub const SAVE_SETTINGS: &str = "_PDFSHXTEXTSAVE"; +pub const OPEN_SETTINGS: &str = "_PDFSHXTEXTSETUP"; +pub const APPLY: &str = "_PDFSHXTEXTAPPLY"; + +inventory::submit!(crate::command::CommandRegistration { + names: &["PDFSHXTEXT", "PDFSHXLAYER", "PDFSHXBESTFONT", "PDFSHXTEXTSETTINGS"] +}); diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index b12716252..9485afcfd 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -27,6 +27,7 @@ pub mod open_progress; pub mod options; pub mod pc_manager; pub mod pdf_dialogs; +pub mod pdf_shx_dialog; pub mod plot; pub mod plugin_manager; pub mod print_all; diff --git a/src/ui/window/pdf_shx_dialog.rs b/src/ui/window/pdf_shx_dialog.rs new file mode 100644 index 000000000..18f19ce9d --- /dev/null +++ b/src/ui/window/pdf_shx_dialog.rs @@ -0,0 +1,400 @@ +//! PDF Text Recognition Settings (PDFSHXTEXT's SEttings): a preview of the +//! chosen font with a sample text on top; the SHX fonts to compare as a +//! numbered list of switches (dragged or moved with the arrows of the chosen +//! row, added from a file, removed); the layer the text goes on; the +//! recognition threshold and "use best matching font". + +use iced::widget::{button, column, container, mouse_area, row, scrollable, text, text_input, toggler, Space}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::modules::insert::pdf_shx_text::ShxTextSettings; +use crate::scene::model::wire_model::WireModel; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, dialog_button, field_style}; +use crate::ui::window::pdf_dialogs::{accent_text, card_style, well_style}; + +/// One edit in the PDF Text Recognition Settings dialog. +#[derive(Debug, Clone)] +pub enum ShxDialogMsg { + Select(usize), + Toggle(usize, bool), + Up, + Down, + Remove, + Add, + Added(Option), + /// The drag handle of a row was pressed. + DragStart(usize), + /// The mouse was released over a row. + DragDrop(usize), + Threshold(u8), + ThresholdText(String), + Step(i8), + CurrentLayer(bool), + BestFont(bool), + Sample(String), + Defaults, + Help, + Apply, +} + +fn msg(m: ShxDialogMsg) -> Message { + Message::PdfShxDialog(m) +} + +pub struct ShxSettingsState { + pub settings: ShxTextSettings, + pub selected: usize, + pub threshold_text: String, + pub sample: String, + pub dragging: Option, + /// The folder of the drawing, where a font may sit next to it. + pub drawing: Option, + pub preview: Vec, +} + +impl ShxSettingsState { + pub fn new(settings: ShxTextSettings, drawing: Option, sample: String) -> Self { + let mut state = Self { + threshold_text: settings.threshold.to_string(), + settings, + selected: 0, + sample, + dragging: None, + drawing, + preview: Vec::new(), + }; + state.refresh_preview(); + state + } + + /// The sample text in the chosen font. + pub fn refresh_preview(&mut self) { + self.preview.clear(); + let Some(font) = self.settings.fonts.get(self.selected) else { + return; + }; + let spec = crate::modules::insert::pdf_shx_text::resolve_font( + &font.name, + self.drawing.as_deref(), + ); + let (strokes, _) = crate::scene::text::lff::tessellate_text_ex( + [0.0, 0.0], + 9.0, + 0.0, + 1.0, + 0.0, + &spec, + &self.sample, + ); + self.preview = strokes + .into_iter() + .filter(|s| s.len() >= 2) + .map(|s| { + WireModel::solid( + "pdfshx_preview".into(), + s.into_iter().map(|p| [p[0], p[1], 0.0]).collect(), + [0.86, 0.86, 0.86, 1.0], + false, + ) + }) + .collect(); + } + + pub fn set_threshold(&mut self, value: u8) { + self.settings.threshold = value.clamp(1, 100); + self.threshold_text = self.settings.threshold.to_string(); + } + + /// Moves the font at `from` to `to`, keeping it chosen. + pub fn move_font(&mut self, from: usize, to: usize) { + let n = self.settings.fonts.len(); + if from >= n || to >= n || from == to { + return; + } + let font = self.settings.fonts.remove(from); + self.settings.fonts.insert(to, font); + self.selected = to; + } +} + +fn section<'a>(title: String, aside: Option, content: impl Into>) -> Element<'a, Message> { + let mut head = row![text(title.to_uppercase()).size(10).style(accent_text)] + .align_y(iced::Center); + if let Some(aside) = aside { + head = head + .push(Space::new().width(Fill)) + .push(text(aside).size(10).style(muted_style)); + } + container(column![head, content.into()].spacing(8)) + .padding([10, 12]) + .width(Fill) + .style(card_style) + .into() +} + +fn small_button<'a>(label: &'static str, message: ShxDialogMsg, danger: bool) -> Element<'a, Message> { + button( + text(label) + .size(11) + .align_x(iced::Center) + .style(move |theme: &Theme| text::Style { + color: Some(if danger { + iced::Color::from_rgb8(0xff, 0x5a, 0x4f) + } else { + theme.palette().background.base.text + }), + }), + ) + .on_press(msg(message)) + .style(button_style(false)) + .padding([2, 8]) + .into() +} + +fn font_row<'a>(state: &'a ShxSettingsState, index: usize) -> Element<'a, Message> { + let font = &state.settings.fonts[index]; + let chosen = index == state.selected; + let grip = mouse_area( + container(text("⠿").size(12).style(muted_style)).padding([0, 2]), + ) + .on_press(msg(ShxDialogMsg::DragStart(index))) + .interaction(iced::mouse::Interaction::Grab); + let switch = toggler(font.enabled) + .size(16.0) + .on_toggle(move |on| msg(ShxDialogMsg::Toggle(index, on))); + let enabled = font.enabled; + let name = text(font.label()).size(12).style(move |theme: &Theme| { + if enabled { + text::Style::default() + } else { + muted_style(theme) + } + }); + let mut line = row![ + text(format!("{}", index + 1)) + .size(10) + .style(muted_style) + .width(Length::Fixed(16.0)) + .align_x(iced::alignment::Horizontal::Right), + grip, + switch, + button(name.width(Fill)) + .on_press(msg(ShxDialogMsg::Select(index))) + .style(button::text) + .padding([2, 4]) + .width(Fill), + ] + .spacing(8) + .align_y(iced::Center); + if chosen { + line = line + .push(small_button("↑", ShxDialogMsg::Up, false)) + .push(small_button("↓", ShxDialogMsg::Down, false)) + .push(small_button("✕", ShxDialogMsg::Remove, true)); + } + let body = container(line) + .padding([3, 6]) + .width(Fill) + .style(move |theme: &Theme| { + if chosen { + container::Style { + background: Some(Background::Color( + theme.palette().primary.base.color.scale_alpha(0.13), + )), + border: Border { + width: 1.0, + radius: 5.0.into(), + color: theme.palette().primary.base.color.scale_alpha(0.45), + }, + ..Default::default() + } + } else { + container::Style::default() + } + }); + mouse_area(body) + .on_release(msg(ShxDialogMsg::DragDrop(index))) + .into() +} + +pub fn view<'a>( + state: &'a ShxSettingsState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let s = &state.settings; + let chosen_label = s + .fonts + .get(state.selected) + .map(|f| f.label()) + .unwrap_or_default(); + let preview = container(crate::ui::window::block_palette::preview_canvas( + &state.preview, + 86.0, + )) + .padding([6, 10]) + .width(Fill) + .style(well_style); + let sample = text_input("", &state.sample) + .on_input(|v| msg(ShxDialogMsg::Sample(v))) + .size(12) + .padding([5, 8]) + .width(Length::Fixed(270.0)) + .style(field_style); + let preview_card = container( + column![ + row![ + text(format!("{} · {}", crate::tr!("pdf-shx", "preview"), chosen_label).to_uppercase()) + .size(10) + .style(accent_text), + Space::new().width(Fill), + sample + ] + .align_y(iced::Center), + preview + ] + .spacing(8), + ) + .padding([10, 12]) + .width(Fill) + .style(card_style); + + let enabled = s.fonts.iter().filter(|f| f.enabled).count(); + let mut list = column![].spacing(2); + for index in 0..s.fonts.len() { + list = list.push(font_row(state, index)); + } + let add = button(text(crate::tr!("pdf-shx", "add-font")).size(11)) + .on_press(msg(ShxDialogMsg::Add)) + .padding([5, 12]) + .style(|theme: &Theme, status| { + let mut style = button_style(false)(theme, status); + style.border = Border { + width: 1.0, + radius: 14.0.into(), + color: theme.palette().primary.base.color, + }; + style + }); + let fonts_card = section( + crate::tr!("pdf-shx", "fonts"), + Some(crate::tr!( + "pdf-shx", + "enabled-count", + on = enabled.to_string(), + all = s.fonts.len().to_string() + )), + column![ + text(crate::tr!("pdf-shx", "fonts-hint")).size(10).style(muted_style), + scrollable(list).height(Length::Fixed(250.0)), + add, + ] + .spacing(8), + ); + + let segment = |current: bool, label: String| { + button(text(label).size(12).width(Fill).align_x(iced::Center)) + .on_press(msg(ShxDialogMsg::CurrentLayer(current))) + .style(button_style(s.current_layer == current)) + .padding([6, 8]) + .width(Fill) + }; + let layer_card = section( + crate::tr!("pdf-shx", "layer"), + None, + container( + row![ + segment(true, crate::tr!("pdf-shx", "current-layer")), + segment(false, crate::tr!("pdf-shx", "geometry-layer")), + ] + .spacing(2), + ) + .padding(2) + .width(Fill) + .style(well_style), + ); + + let slide = iced::widget::slider(1..=100u8, s.threshold, |v| msg(ShxDialogMsg::Threshold(v))) + .height(16.0) + .width(Fill); + let step = |dir: i8| { + button(text(if dir < 0 { "\u{2212}" } else { "+" }).size(12)) + .on_press(msg(ShxDialogMsg::Step(dir))) + .style(button_style(false)) + .padding([2, 8]) + }; + let value = text_input("", &state.threshold_text) + .on_input(|v| msg(ShxDialogMsg::ThresholdText(v))) + .size(11) + .padding([3, 4]) + .width(Length::Fixed(36.0)) + .align_x(iced::alignment::Horizontal::Right) + .style(field_style); + let stepper = container( + row![step(-1), value, text("%").size(11), step(1)] + .spacing(4) + .align_y(iced::Center), + ) + .padding(2) + .style(well_style); + let recognition_card = section( + crate::tr!("pdf-shx", "recognition"), + None, + column![ + text(crate::tr!("pdf-shx", "threshold")).size(11).style(muted_style), + row![slide, stepper].spacing(10).align_y(iced::Center), + row![ + toggler(s.best_font) + .size(16.0) + .on_toggle(|on| msg(ShxDialogMsg::BestFont(on))), + text(crate::tr!("pdf-shx", "best-font")).size(12).width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + text(crate::tr!("pdf-shx", "hint")).size(10).style(muted_style), + ] + .spacing(10), + ); + + let defaults = button( + text(crate::tr!("pdf-shx", "defaults")) + .size(11) + .style(accent_text), + ) + .on_press(msg(ShxDialogMsg::Defaults)) + .style(button::text) + .padding([5, 4]); + let footer = row![ + button(text("?").size(12)) + .on_press(msg(ShxDialogMsg::Help)) + .style(button_style(false)) + .padding([5, 11]), + defaults, + Space::new().width(Fill), + dialog_button(crate::t!("Cancel"), Message::CloseModal, false), + dialog_button( + std::borrow::Cow::Owned(crate::tr!("pdf-shx", "apply")), + msg(ShxDialogMsg::Apply), + true + ), + ] + .spacing(6) + .align_y(iced::Center); + + column![ + preview_card, + row![ + container(fonts_card).width(Length::FillPortion(11)), + column![layer_card, recognition_card] + .spacing(10) + .width(Length::FillPortion(7)), + ] + .spacing(10), + footer + ] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +}