diff --git a/README.md b/README.md index 233f428..c2d6a83 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ Legend: | Section | Proto Commands | Implemented | Coverage | | --- | ---: | ---: | ---: | -| Common (base) | 6 | 2 | 33% | +| Common (base) | 6 | 3 | 50% | | Common editor/document | 23 | 9 | 39% | | Project manager | 5 | 3 | 60% | | Board editor (PCB) | 22 | 13 | 59% | | Schematic editor (dedicated proto commands) | 0 | 0 | n/a | -| **Total** | **56** | **27** | **48%** | +| **Total** | **56** | **28** | **50%** | ### Common (base) @@ -52,7 +52,7 @@ Legend: | `Ping` | Implemented | `KiCadClient::ping` | | `GetVersion` | Implemented | `KiCadClient::get_version` | | `GetKiCadBinaryPath` | Not yet | - | -| `GetTextExtents` | Not yet | - | +| `GetTextExtents` | Implemented | `KiCadClient::get_text_extents_raw`, `KiCadClient::get_text_extents` | | `GetTextAsShapes` | Not yet | - | | `GetPluginSettingsPath` | Not yet | - | diff --git a/docs/TEST_CLI.md b/docs/TEST_CLI.md index f2c3cf1..cb1e17e 100644 --- a/docs/TEST_CLI.md +++ b/docs/TEST_CLI.md @@ -65,6 +65,12 @@ Expand text variables in one or more input strings: cargo run --bin kicad-ipc-cli -- expand-text-variables --text "${TITLE}" --text "${REVISION}" ``` +Measure text extents: + +```bash +cargo run --bin kicad-ipc-cli -- text-extents --text "R1" +``` + List enabled board layers: ```bash diff --git a/src/client.rs b/src/client.rs index e69af24..555d882 100644 --- a/src/client.rs +++ b/src/client.rs @@ -19,7 +19,8 @@ use crate::model::board::{ }; use crate::model::common::{ DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode, - ProjectInfo, SelectionItemDetail, SelectionSummary, SelectionTypeCount, TitleBlockInfo, + ProjectInfo, SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAttributesSpec, + TextExtents, TextHorizontalAlignment, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo, }; use crate::proto::kiapi::board as board_proto; @@ -38,6 +39,7 @@ const CMD_GET_VERSION: &str = "kiapi.common.commands.GetVersion"; const CMD_GET_NET_CLASSES: &str = "kiapi.common.commands.GetNetClasses"; const CMD_GET_TEXT_VARIABLES: &str = "kiapi.common.commands.GetTextVariables"; const CMD_EXPAND_TEXT_VARIABLES: &str = "kiapi.common.commands.ExpandTextVariables"; +const CMD_GET_TEXT_EXTENTS: &str = "kiapi.common.commands.GetTextExtents"; const CMD_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocuments"; const CMD_GET_NETS: &str = "kiapi.board.commands.GetNets"; const CMD_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.GetBoardEnabledLayers"; @@ -68,6 +70,7 @@ const RES_NET_CLASSES_RESPONSE: &str = "kiapi.common.commands.NetClassesResponse const RES_TEXT_VARIABLES: &str = "kiapi.common.project.TextVariables"; const RES_EXPAND_TEXT_VARIABLES_RESPONSE: &str = "kiapi.common.commands.ExpandTextVariablesResponse"; +const RES_BOX2: &str = "kiapi.common.types.Box2"; const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; const RES_GET_NETS: &str = "kiapi.board.commands.NetsResponse"; const RES_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.BoardEnabledLayersResponse"; @@ -386,6 +389,39 @@ impl KiCadClient { Ok(response.text) } + pub async fn get_text_extents_raw( + &self, + text: TextSpec, + ) -> Result { + let command = common_commands::GetTextExtents { + text: Some(text_spec_to_proto(text)), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_TEXT_EXTENTS)) + .await?; + response_payload_as_any(response, RES_BOX2) + } + + pub async fn get_text_extents(&self, text: TextSpec) -> Result { + let payload = self.get_text_extents_raw(text).await?; + let response: common_types::Box2 = decode_any(&payload, RES_BOX2)?; + let position = response + .position + .ok_or_else(|| KiCadError::InvalidResponse { + reason: "GetTextExtents response missing position".to_string(), + })?; + let size = response.size.ok_or_else(|| KiCadError::InvalidResponse { + reason: "GetTextExtents response missing size".to_string(), + })?; + + Ok(TextExtents { + x_nm: position.x_nm, + y_nm: position.y_nm, + width_nm: size.x_nm, + height_nm: size.y_nm, + }) + } + pub async fn get_current_project_path(&self) -> Result { let docs = self.get_open_documents(DocumentType::Pcb).await?; select_single_project_path(&docs) @@ -1194,6 +1230,62 @@ fn model_document_to_proto(document: &DocumentSpecifier) -> common_types::Docume } } +fn text_spec_to_proto(text: TextSpec) -> common_types::Text { + common_types::Text { + position: text.position_nm.map(vector2_nm_to_proto), + attributes: text.attributes.map(text_attributes_spec_to_proto), + text: text.text, + hyperlink: text.hyperlink.unwrap_or_default(), + } +} + +fn text_attributes_spec_to_proto(attributes: TextAttributesSpec) -> common_types::TextAttributes { + common_types::TextAttributes { + font_name: attributes.font_name.unwrap_or_default(), + horizontal_alignment: text_horizontal_alignment_to_proto(attributes.horizontal_alignment), + vertical_alignment: text_vertical_alignment_to_proto(attributes.vertical_alignment), + angle: attributes + .angle_degrees + .map(|value_degrees| common_types::Angle { value_degrees }), + line_spacing: attributes.line_spacing.unwrap_or(1.0), + stroke_width: attributes + .stroke_width_nm + .map(|value_nm| common_types::Distance { value_nm }), + italic: attributes.italic, + bold: attributes.bold, + underlined: attributes.underlined, + visible: true, + mirrored: attributes.mirrored, + multiline: attributes.multiline, + keep_upright: attributes.keep_upright, + size: attributes.size_nm.map(vector2_nm_to_proto), + } +} + +fn text_horizontal_alignment_to_proto(value: TextHorizontalAlignment) -> i32 { + match value { + TextHorizontalAlignment::Unknown => common_types::HorizontalAlignment::HaUnknown as i32, + TextHorizontalAlignment::Left => common_types::HorizontalAlignment::HaLeft as i32, + TextHorizontalAlignment::Center => common_types::HorizontalAlignment::HaCenter as i32, + TextHorizontalAlignment::Right => common_types::HorizontalAlignment::HaRight as i32, + TextHorizontalAlignment::Indeterminate => { + common_types::HorizontalAlignment::HaIndeterminate as i32 + } + } +} + +fn text_vertical_alignment_to_proto(value: TextVerticalAlignment) -> i32 { + match value { + TextVerticalAlignment::Unknown => common_types::VerticalAlignment::VaUnknown as i32, + TextVerticalAlignment::Top => common_types::VerticalAlignment::VaTop as i32, + TextVerticalAlignment::Center => common_types::VerticalAlignment::VaCenter as i32, + TextVerticalAlignment::Bottom => common_types::VerticalAlignment::VaBottom as i32, + TextVerticalAlignment::Indeterminate => { + common_types::VerticalAlignment::VaIndeterminate as i32 + } + } +} + fn layer_to_model(layer_id: i32) -> BoardLayerInfo { let name = board_types::BoardLayer::try_from(layer_id) .map(|layer| layer.as_str_name().to_string()) @@ -1353,6 +1445,13 @@ fn map_vector2_nm(value: common_types::Vector2) -> Vector2Nm { } } +fn vector2_nm_to_proto(value: Vector2Nm) -> common_types::Vector2 { + common_types::Vector2 { + x_nm: value.x_nm, + y_nm: value.y_nm, + } +} + fn decode_any( payload: &prost_types::Any, expected_type_name: &str, @@ -2307,10 +2406,14 @@ mod tests { map_item_bounding_boxes, map_polygon_with_holes, model_document_to_proto, normalize_socket_uri, pad_netlist_from_footprint_items, select_single_board_document, select_single_project_path, selection_item_detail, summarize_item_details, - summarize_selection, PCB_OBJECT_TYPES, + summarize_selection, text_horizontal_alignment_to_proto, text_spec_to_proto, + PCB_OBJECT_TYPES, }; use crate::error::KiCadError; - use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo}; + use crate::model::common::{ + DocumentSpecifier, DocumentType, ProjectInfo, TextAttributesSpec, TextHorizontalAlignment, + TextSpec, + }; use prost::Message; use std::path::PathBuf; @@ -2652,6 +2755,48 @@ mod tests { ); } + #[test] + fn text_horizontal_alignment_to_proto_covers_known_variants() { + assert_eq!( + text_horizontal_alignment_to_proto(TextHorizontalAlignment::Left), + crate::proto::kiapi::common::types::HorizontalAlignment::HaLeft as i32 + ); + assert_eq!( + text_horizontal_alignment_to_proto(TextHorizontalAlignment::Indeterminate), + crate::proto::kiapi::common::types::HorizontalAlignment::HaIndeterminate as i32 + ); + } + + #[test] + fn text_spec_to_proto_maps_optional_fields() { + let spec = TextSpec { + text: "R1".to_string(), + position_nm: Some(crate::model::board::Vector2Nm { + x_nm: 1_000, + y_nm: 2_000, + }), + attributes: Some(TextAttributesSpec { + font_name: Some("KiCad Font".to_string()), + horizontal_alignment: TextHorizontalAlignment::Center, + ..TextAttributesSpec::default() + }), + hyperlink: Some("https://example.com".to_string()), + }; + + let proto = text_spec_to_proto(spec); + assert_eq!(proto.text, "R1"); + assert_eq!(proto.hyperlink, "https://example.com"); + let position = proto.position.expect("position should be present"); + assert_eq!(position.x_nm, 1_000); + assert_eq!(position.y_nm, 2_000); + let attributes = proto.attributes.expect("attributes should be present"); + assert_eq!(attributes.font_name, "KiCad Font"); + assert_eq!( + attributes.horizontal_alignment, + crate::proto::kiapi::common::types::HorizontalAlignment::HaCenter as i32 + ); + } + #[test] fn pcb_object_type_catalog_contains_expected_trace_entry() { assert!(PCB_OBJECT_TYPES diff --git a/src/lib.rs b/src/lib.rs index bf21f5c..396c225 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,5 +34,6 @@ pub use crate::model::board::{ }; pub use crate::model::common::{ DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode, - SelectionItemDetail, SelectionSummary, SelectionTypeCount, TitleBlockInfo, VersionInfo, + SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAttributesSpec, TextExtents, + TextHorizontalAlignment, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo, }; diff --git a/src/model/common.rs b/src/model/common.rs index 9c92058..e7e8703 100644 --- a/src/model/common.rs +++ b/src/model/common.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use std::str::FromStr; +use crate::model::board::Vector2Nm; use crate::proto::kiapi::common::types as common_types; #[derive(Clone, Debug, Eq, PartialEq)] @@ -143,6 +144,88 @@ pub struct PcbObjectTypeCode { pub name: &'static str, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TextHorizontalAlignment { + Unknown, + Left, + Center, + Right, + Indeterminate, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TextVerticalAlignment { + Unknown, + Top, + Center, + Bottom, + Indeterminate, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextAttributesSpec { + pub font_name: Option, + pub horizontal_alignment: TextHorizontalAlignment, + pub vertical_alignment: TextVerticalAlignment, + pub angle_degrees: Option, + pub line_spacing: Option, + pub stroke_width_nm: Option, + pub italic: bool, + pub bold: bool, + pub underlined: bool, + pub mirrored: bool, + pub multiline: bool, + pub keep_upright: bool, + pub size_nm: Option, +} + +impl Default for TextAttributesSpec { + fn default() -> Self { + Self { + font_name: None, + horizontal_alignment: TextHorizontalAlignment::Unknown, + vertical_alignment: TextVerticalAlignment::Unknown, + angle_degrees: None, + line_spacing: None, + stroke_width_nm: None, + italic: false, + bold: false, + underlined: false, + mirrored: false, + multiline: false, + keep_upright: false, + size_nm: None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextSpec { + pub text: String, + pub position_nm: Option, + pub attributes: Option, + pub hyperlink: Option, +} + +impl TextSpec { + pub fn plain(text: impl Into) -> Self { + Self { + text: text.into(), + position_nm: None, + attributes: None, + hyperlink: None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TextExtents { + pub x_nm: i64, + pub y_nm: i64, + pub width_nm: i64, + pub height_nm: i64, +} + impl std::fmt::Display for ItemHitTestResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let value = match self { diff --git a/test-scripts/kicad-ipc-cli.rs b/test-scripts/kicad-ipc-cli.rs index f0d0795..bf16fc0 100644 --- a/test-scripts/kicad-ipc-cli.rs +++ b/test-scripts/kicad-ipc-cli.rs @@ -7,7 +7,7 @@ use std::time::Duration; use kicad_ipc::{ BoardOriginKind, ClientBuilder, DocumentType, KiCadClient, KiCadError, PadstackPresenceState, - PcbObjectTypeCode, Vector2Nm, + PcbObjectTypeCode, TextSpec, Vector2Nm, }; const REPORT_MAX_PAD_NET_ROWS: usize = 2_000; @@ -37,6 +37,9 @@ enum Command { ExpandTextVariables { text: Vec, }, + TextExtents { + text: String, + }, Nets, EnabledLayers, ActiveLayer, @@ -214,6 +217,13 @@ async fn run() -> Result<(), KiCadError> { println!("[{index}] input={} expanded={}", text[index], value); } } + Command::TextExtents { text } => { + let extents = client.get_text_extents(TextSpec::plain(text)).await?; + println!( + "x_nm={} y_nm={} width_nm={} height_nm={}", + extents.x_nm, extents.y_nm, extents.width_nm, extents.height_nm + ); + } Command::Nets => { let nets = client.get_nets().await?; if nets.is_empty() { @@ -635,6 +645,30 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { Command::ExpandTextVariables { text } } + "text-extents" => { + let mut text = None; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--text" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for text-extents --text".to_string(), + })?; + text = Some(value.clone()); + i += 2; + } + _ => { + i += 1; + } + } + } + + Command::TextExtents { + text: text.ok_or_else(|| KiCadError::Config { + reason: "text-extents requires `--text `".to_string(), + })?, + } + } "nets" => Command::Nets, "enabled-layers" => Command::EnabledLayers, "active-layer" => Command::ActiveLayer, @@ -970,7 +1004,7 @@ fn default_config() -> CliConfig { fn print_help() { println!( - "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n net-classes List project netclass definitions\n text-variables List text variables for current board document\n expand-text-variables Expand variables in provided text values\n Options: --text (repeatable)\n nets List board nets (requires one open PCB)\n netlist-pads Emit pad-level netlist data (with footprint context)\n items-by-id --id ... Show parsed details for specific item IDs\n item-bbox --id ... Show bounding boxes for item IDs\n hit-test --id --x-nm --y-nm [--tolerance-nm ]\n Hit-test one item at a point\n types-pcb List PCB KiCad object type IDs from proto enum\n items-raw --type-id ... Dump raw Any payloads for requested item type IDs\n items-raw-all-pcb [--debug] Dump all PCB item payloads across all PCB object types\n pad-shape-polygon --pad-id ... --layer-id [--debug]\n Dump pad polygons on a target layer\n padstack-presence --item-id ... --layer-id ... [--debug]\n Check padstack shape presence matrix across layers\n title-block Show title block fields\n board-as-string Dump board as KiCad s-expression text\n selection-as-string Dump current selection as KiCad s-expression text\n stackup Show typed board stackup\n graphics-defaults Show typed graphics defaults\n appearance Show typed editor appearance settings\n netclass Show typed netclass map for current board nets\n proto-coverage-board-read Print board-read command coverage vs proto\n board-read-report [--out P] Write markdown board reconstruction report\n enabled-layers List enabled board layers\n active-layer Show active board layer\n visible-layers Show currently visible board layers\n board-origin [--type ] Show board origin (`grid` default, or `drill`)\n selection-summary Show current selection item type counts\n selection-details Show parsed details for selected items\n selection-raw Show raw Any payload bytes for selected items\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" + "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n net-classes List project netclass definitions\n text-variables List text variables for current board document\n expand-text-variables Expand variables in provided text values\n Options: --text (repeatable)\n text-extents Measure text bounding box\n Options: --text \n nets List board nets (requires one open PCB)\n netlist-pads Emit pad-level netlist data (with footprint context)\n items-by-id --id ... Show parsed details for specific item IDs\n item-bbox --id ... Show bounding boxes for item IDs\n hit-test --id --x-nm --y-nm [--tolerance-nm ]\n Hit-test one item at a point\n types-pcb List PCB KiCad object type IDs from proto enum\n items-raw --type-id ... Dump raw Any payloads for requested item type IDs\n items-raw-all-pcb [--debug] Dump all PCB item payloads across all PCB object types\n pad-shape-polygon --pad-id ... --layer-id [--debug]\n Dump pad polygons on a target layer\n padstack-presence --item-id ... --layer-id ... [--debug]\n Check padstack shape presence matrix across layers\n title-block Show title block fields\n board-as-string Dump board as KiCad s-expression text\n selection-as-string Dump current selection as KiCad s-expression text\n stackup Show typed board stackup\n graphics-defaults Show typed graphics defaults\n appearance Show typed editor appearance settings\n netclass Show typed netclass map for current board nets\n proto-coverage-board-read Print board-read command coverage vs proto\n board-read-report [--out P] Write markdown board reconstruction report\n enabled-layers List enabled board layers\n active-layer Show active board layer\n visible-layers Show currently visible board layers\n board-origin [--type ] Show board origin (`grid` default, or `drill`)\n selection-summary Show current selection item type counts\n selection-details Show parsed details for selected items\n selection-raw Show raw Any payload bytes for selected items\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" ); } @@ -1388,6 +1422,11 @@ fn proto_coverage_board_read_rows() -> Vec<(&'static str, &'static str, &'static "implemented", "expand_text_variables_raw/expand_text_variables", ), + ( + "kiapi.common.commands.GetTextExtents", + "implemented", + "get_text_extents_raw/get_text_extents", + ), ( "kiapi.common.commands.GetItems", "implemented",