feat(base): add GetTextExtents API bindings

This commit is contained in:
Milind Sharma 2026-02-20 14:20:27 +08:00
parent fc9a92502c
commit 5fb0bdccdb
6 changed files with 283 additions and 9 deletions

View File

@ -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 | - |

View File

@ -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

View File

@ -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<prost_types::Any, KiCadError> {
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<TextExtents, KiCadError> {
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<PathBuf, KiCadError> {
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<T: prost::Message + Default>(
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

View File

@ -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,
};

View File

@ -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<String>,
pub horizontal_alignment: TextHorizontalAlignment,
pub vertical_alignment: TextVerticalAlignment,
pub angle_degrees: Option<f64>,
pub line_spacing: Option<f64>,
pub stroke_width_nm: Option<i64>,
pub italic: bool,
pub bold: bool,
pub underlined: bool,
pub mirrored: bool,
pub multiline: bool,
pub keep_upright: bool,
pub size_nm: Option<Vector2Nm>,
}
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<Vector2Nm>,
pub attributes: Option<TextAttributesSpec>,
pub hyperlink: Option<String>,
}
impl TextSpec {
pub fn plain(text: impl Into<String>) -> 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 {

View File

@ -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<String>,
},
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 <value>`".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> [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type <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 <value> (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 <uuid> ... Show parsed details for specific item IDs\n item-bbox --id <uuid> ... Show bounding boxes for item IDs\n hit-test --id <uuid> --x-nm <x> --y-nm <y> [--tolerance-nm <n>]\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 <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 <uuid> ... --layer-id <i32> [--debug]\n Dump pad polygons on a target layer\n padstack-presence --item-id <uuid> ... --layer-id <i32> ... [--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 <t>] 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> [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type <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 <value> (repeatable)\n text-extents Measure text bounding box\n Options: --text <value>\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 <uuid> ... Show parsed details for specific item IDs\n item-bbox --id <uuid> ... Show bounding boxes for item IDs\n hit-test --id <uuid> --x-nm <x> --y-nm <y> [--tolerance-nm <n>]\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 <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 <uuid> ... --layer-id <i32> [--debug]\n Dump pad polygons on a target layer\n padstack-presence --item-id <uuid> ... --layer-id <i32> ... [--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 <t>] 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",