feat(base): add GetTextAsShapes API bindings

This commit is contained in:
Milind Sharma 2026-02-20 14:24:05 +08:00
parent 5fb0bdccdb
commit e644eb0ac5
6 changed files with 371 additions and 11 deletions

View File

@ -38,12 +38,12 @@ Legend:
| Section | Proto Commands | Implemented | Coverage |
| --- | ---: | ---: | ---: |
| Common (base) | 6 | 3 | 50% |
| Common (base) | 6 | 4 | 67% |
| 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** | **28** | **50%** |
| **Total** | **56** | **29** | **52%** |
### Common (base)
@ -53,7 +53,7 @@ Legend:
| `GetVersion` | Implemented | `KiCadClient::get_version` |
| `GetKiCadBinaryPath` | Not yet | - |
| `GetTextExtents` | Implemented | `KiCadClient::get_text_extents_raw`, `KiCadClient::get_text_extents` |
| `GetTextAsShapes` | Not yet | - |
| `GetTextAsShapes` | Implemented | `KiCadClient::get_text_as_shapes_raw`, `KiCadClient::get_text_as_shapes` |
| `GetPluginSettingsPath` | Not yet | - |
### Common editor/document

View File

@ -71,6 +71,12 @@ Measure text extents:
cargo run --bin kicad-ipc-cli -- text-extents --text "R1"
```
Convert text to shape primitives:
```bash
cargo run --bin kicad-ipc-cli -- text-as-shapes --text "R1" --text "C5"
```
List enabled board layers:
```bash

View File

@ -19,9 +19,9 @@ use crate::model::board::{
};
use crate::model::common::{
DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode,
ProjectInfo, SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAttributesSpec,
TextExtents, TextHorizontalAlignment, TextSpec, TextVerticalAlignment, TitleBlockInfo,
VersionInfo,
ProjectInfo, SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAsShapesEntry,
TextAttributesSpec, TextBoxSpec, TextExtents, TextHorizontalAlignment, TextObjectSpec,
TextShape, TextShapeGeometry, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo,
};
use crate::proto::kiapi::board as board_proto;
use crate::proto::kiapi::board::commands as board_commands;
@ -40,6 +40,7 @@ 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_TEXT_AS_SHAPES: &str = "kiapi.common.commands.GetTextAsShapes";
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";
@ -71,6 +72,7 @@ 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_TEXT_AS_SHAPES_RESPONSE: &str = "kiapi.common.commands.GetTextAsShapesResponse";
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";
@ -422,6 +424,34 @@ impl KiCadClient {
})
}
pub async fn get_text_as_shapes_raw(
&self,
text: Vec<TextObjectSpec>,
) -> Result<prost_types::Any, KiCadError> {
let command = common_commands::GetTextAsShapes {
text: text.into_iter().map(text_object_spec_to_proto).collect(),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_TEXT_AS_SHAPES))
.await?;
response_payload_as_any(response, RES_GET_TEXT_AS_SHAPES_RESPONSE)
}
pub async fn get_text_as_shapes(
&self,
text: Vec<TextObjectSpec>,
) -> Result<Vec<TextAsShapesEntry>, KiCadError> {
let payload = self.get_text_as_shapes_raw(text).await?;
let response: common_commands::GetTextAsShapesResponse =
decode_any(&payload, RES_GET_TEXT_AS_SHAPES_RESPONSE)?;
response
.text_with_shapes
.into_iter()
.map(map_text_with_shapes)
.collect()
}
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)
@ -1286,6 +1316,191 @@ fn text_vertical_alignment_to_proto(value: TextVerticalAlignment) -> i32 {
}
}
fn text_box_spec_to_proto(text: TextBoxSpec) -> common_types::TextBox {
common_types::TextBox {
top_left: text.top_left_nm.map(vector2_nm_to_proto),
bottom_right: text.bottom_right_nm.map(vector2_nm_to_proto),
attributes: text.attributes.map(text_attributes_spec_to_proto),
text: text.text,
}
}
fn text_object_spec_to_proto(text: TextObjectSpec) -> common_commands::TextOrTextBox {
let inner = match text {
TextObjectSpec::Text(value) => {
common_commands::text_or_text_box::Inner::Text(text_spec_to_proto(value))
}
TextObjectSpec::TextBox(value) => {
common_commands::text_or_text_box::Inner::Textbox(text_box_spec_to_proto(value))
}
};
common_commands::TextOrTextBox { inner: Some(inner) }
}
fn map_text_horizontal_alignment_from_proto(value: i32) -> TextHorizontalAlignment {
match common_types::HorizontalAlignment::try_from(value) {
Ok(common_types::HorizontalAlignment::HaLeft) => TextHorizontalAlignment::Left,
Ok(common_types::HorizontalAlignment::HaCenter) => TextHorizontalAlignment::Center,
Ok(common_types::HorizontalAlignment::HaRight) => TextHorizontalAlignment::Right,
Ok(common_types::HorizontalAlignment::HaIndeterminate) => {
TextHorizontalAlignment::Indeterminate
}
_ => TextHorizontalAlignment::Unknown,
}
}
fn map_text_vertical_alignment_from_proto(value: i32) -> TextVerticalAlignment {
match common_types::VerticalAlignment::try_from(value) {
Ok(common_types::VerticalAlignment::VaTop) => TextVerticalAlignment::Top,
Ok(common_types::VerticalAlignment::VaCenter) => TextVerticalAlignment::Center,
Ok(common_types::VerticalAlignment::VaBottom) => TextVerticalAlignment::Bottom,
Ok(common_types::VerticalAlignment::VaIndeterminate) => {
TextVerticalAlignment::Indeterminate
}
_ => TextVerticalAlignment::Unknown,
}
}
fn map_text_attributes_spec_from_proto(
attributes: common_types::TextAttributes,
) -> TextAttributesSpec {
TextAttributesSpec {
font_name: if attributes.font_name.is_empty() {
None
} else {
Some(attributes.font_name)
},
horizontal_alignment: map_text_horizontal_alignment_from_proto(
attributes.horizontal_alignment,
),
vertical_alignment: map_text_vertical_alignment_from_proto(attributes.vertical_alignment),
angle_degrees: attributes.angle.map(|value| value.value_degrees),
line_spacing: Some(attributes.line_spacing),
stroke_width_nm: map_optional_distance_nm(attributes.stroke_width),
italic: attributes.italic,
bold: attributes.bold,
underlined: attributes.underlined,
mirrored: attributes.mirrored,
multiline: attributes.multiline,
keep_upright: attributes.keep_upright,
size_nm: attributes.size.map(map_vector2_nm),
}
}
fn map_text_spec_from_proto(text: common_types::Text) -> TextSpec {
TextSpec {
text: text.text,
position_nm: text.position.map(map_vector2_nm),
attributes: text.attributes.map(map_text_attributes_spec_from_proto),
hyperlink: if text.hyperlink.is_empty() {
None
} else {
Some(text.hyperlink)
},
}
}
fn map_text_box_spec_from_proto(text: common_types::TextBox) -> TextBoxSpec {
TextBoxSpec {
text: text.text,
top_left_nm: text.top_left.map(map_vector2_nm),
bottom_right_nm: text.bottom_right.map(map_vector2_nm),
attributes: text.attributes.map(map_text_attributes_spec_from_proto),
}
}
fn map_text_object_spec_from_proto(text: common_commands::TextOrTextBox) -> Option<TextObjectSpec> {
match text.inner {
Some(common_commands::text_or_text_box::Inner::Text(value)) => {
Some(TextObjectSpec::Text(map_text_spec_from_proto(value)))
}
Some(common_commands::text_or_text_box::Inner::Textbox(value)) => {
Some(TextObjectSpec::TextBox(map_text_box_spec_from_proto(value)))
}
None => None,
}
}
fn map_text_shape_geometry(
shape: common_types::GraphicShape,
) -> Result<TextShapeGeometry, KiCadError> {
match shape.geometry {
Some(common_types::graphic_shape::Geometry::Segment(segment)) => {
Ok(TextShapeGeometry::Segment {
start_nm: segment.start.map(map_vector2_nm),
end_nm: segment.end.map(map_vector2_nm),
})
}
Some(common_types::graphic_shape::Geometry::Rectangle(rectangle)) => {
Ok(TextShapeGeometry::Rectangle {
top_left_nm: rectangle.top_left.map(map_vector2_nm),
bottom_right_nm: rectangle.bottom_right.map(map_vector2_nm),
corner_radius_nm: map_optional_distance_nm(rectangle.corner_radius),
})
}
Some(common_types::graphic_shape::Geometry::Arc(arc)) => Ok(TextShapeGeometry::Arc {
start_nm: arc.start.map(map_vector2_nm),
mid_nm: arc.mid.map(map_vector2_nm),
end_nm: arc.end.map(map_vector2_nm),
}),
Some(common_types::graphic_shape::Geometry::Circle(circle)) => {
Ok(TextShapeGeometry::Circle {
center_nm: circle.center.map(map_vector2_nm),
radius_point_nm: circle.radius_point.map(map_vector2_nm),
})
}
Some(common_types::graphic_shape::Geometry::Polygon(polygon)) => {
let polygons = polygon
.polygons
.into_iter()
.map(map_polygon_with_holes)
.collect::<Result<Vec<_>, _>>()?;
Ok(TextShapeGeometry::Polygon { polygons })
}
Some(common_types::graphic_shape::Geometry::Bezier(bezier)) => {
Ok(TextShapeGeometry::Bezier {
start_nm: bezier.start.map(map_vector2_nm),
control1_nm: bezier.control1.map(map_vector2_nm),
control2_nm: bezier.control2.map(map_vector2_nm),
end_nm: bezier.end.map(map_vector2_nm),
})
}
None => Ok(TextShapeGeometry::Unknown),
}
}
fn map_text_shape(shape: common_types::GraphicShape) -> Result<TextShape, KiCadError> {
let geometry = map_text_shape_geometry(shape.clone())?;
let attributes = shape.attributes.unwrap_or_default();
let stroke = attributes.stroke;
let fill = attributes.fill;
Ok(TextShape {
geometry,
stroke_width_nm: stroke
.clone()
.and_then(|value| map_optional_distance_nm(value.width)),
stroke_style: stroke.as_ref().map(|value| value.style),
stroke_color: stroke.and_then(|value| map_optional_color(value.color)),
fill_type: fill.as_ref().map(|value| value.fill_type),
fill_color: fill.and_then(|value| map_optional_color(value.color)),
})
}
fn map_text_with_shapes(
row: common_commands::TextWithShapes,
) -> Result<TextAsShapesEntry, KiCadError> {
let source = row.text.and_then(map_text_object_spec_from_proto);
let shapes = row
.shapes
.unwrap_or_default()
.shapes
.into_iter()
.map(map_text_shape)
.collect::<Result<Vec<_>, _>>()?;
Ok(TextAsShapesEntry { source, shapes })
}
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())

View File

@ -34,6 +34,7 @@ pub use crate::model::board::{
};
pub use crate::model::common::{
DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode,
SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAttributesSpec, TextExtents,
TextHorizontalAlignment, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo,
SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAsShapesEntry,
TextAttributesSpec, TextBoxSpec, TextExtents, TextHorizontalAlignment, TextObjectSpec,
TextShape, TextShapeGeometry, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo,
};

View File

@ -1,7 +1,7 @@
use std::path::PathBuf;
use std::str::FromStr;
use crate::model::board::Vector2Nm;
use crate::model::board::{ColorRgba, PolygonWithHolesNm, Vector2Nm};
use crate::proto::kiapi::common::types as common_types;
#[derive(Clone, Debug, Eq, PartialEq)]
@ -226,6 +226,68 @@ pub struct TextExtents {
pub height_nm: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextBoxSpec {
pub text: String,
pub top_left_nm: Option<Vector2Nm>,
pub bottom_right_nm: Option<Vector2Nm>,
pub attributes: Option<TextAttributesSpec>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum TextObjectSpec {
Text(TextSpec),
TextBox(TextBoxSpec),
}
#[derive(Clone, Debug, PartialEq)]
pub enum TextShapeGeometry {
Segment {
start_nm: Option<Vector2Nm>,
end_nm: Option<Vector2Nm>,
},
Rectangle {
top_left_nm: Option<Vector2Nm>,
bottom_right_nm: Option<Vector2Nm>,
corner_radius_nm: Option<i64>,
},
Arc {
start_nm: Option<Vector2Nm>,
mid_nm: Option<Vector2Nm>,
end_nm: Option<Vector2Nm>,
},
Circle {
center_nm: Option<Vector2Nm>,
radius_point_nm: Option<Vector2Nm>,
},
Polygon {
polygons: Vec<PolygonWithHolesNm>,
},
Bezier {
start_nm: Option<Vector2Nm>,
control1_nm: Option<Vector2Nm>,
control2_nm: Option<Vector2Nm>,
end_nm: Option<Vector2Nm>,
},
Unknown,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextShape {
pub geometry: TextShapeGeometry,
pub stroke_width_nm: Option<i64>,
pub stroke_style: Option<i32>,
pub stroke_color: Option<ColorRgba>,
pub fill_type: Option<i32>,
pub fill_color: Option<ColorRgba>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextAsShapesEntry {
pub source: Option<TextObjectSpec>,
pub shapes: Vec<TextShape>,
}
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, TextSpec, Vector2Nm,
PcbObjectTypeCode, TextObjectSpec, TextShapeGeometry, TextSpec, Vector2Nm,
};
const REPORT_MAX_PAD_NET_ROWS: usize = 2_000;
@ -40,6 +40,9 @@ enum Command {
TextExtents {
text: String,
},
TextAsShapes {
text: Vec<String>,
},
Nets,
EnabledLayers,
ActiveLayer,
@ -224,6 +227,47 @@ async fn run() -> Result<(), KiCadError> {
extents.x_nm, extents.y_nm, extents.width_nm, extents.height_nm
);
}
Command::TextAsShapes { text } => {
let entries = client
.get_text_as_shapes(
text.into_iter()
.map(|value| TextObjectSpec::Text(TextSpec::plain(value)))
.collect(),
)
.await?;
println!("text_with_shapes_count={}", entries.len());
for (index, entry) in entries.iter().enumerate() {
let mut segment_count = 0;
let mut rectangle_count = 0;
let mut arc_count = 0;
let mut circle_count = 0;
let mut polygon_count = 0;
let mut bezier_count = 0;
let mut unknown_count = 0;
for shape in &entry.shapes {
match shape.geometry {
TextShapeGeometry::Segment { .. } => segment_count += 1,
TextShapeGeometry::Rectangle { .. } => rectangle_count += 1,
TextShapeGeometry::Arc { .. } => arc_count += 1,
TextShapeGeometry::Circle { .. } => circle_count += 1,
TextShapeGeometry::Polygon { .. } => polygon_count += 1,
TextShapeGeometry::Bezier { .. } => bezier_count += 1,
TextShapeGeometry::Unknown => unknown_count += 1,
}
}
println!(
"[{index}] shape_count={} segment={} rectangle={} arc={} circle={} polygon={} bezier={} unknown={}",
entry.shapes.len(),
segment_count,
rectangle_count,
arc_count,
circle_count,
polygon_count,
bezier_count,
unknown_count
);
}
}
Command::Nets => {
let nets = client.get_nets().await?;
if nets.is_empty() {
@ -669,6 +713,33 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> {
})?,
}
}
"text-as-shapes" => {
let mut text = Vec::new();
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-as-shapes --text".to_string(),
})?;
text.push(value.clone());
i += 2;
}
_ => {
i += 1;
}
}
}
if text.is_empty() {
return Err(KiCadError::Config {
reason: "text-as-shapes requires one or more `--text <value>` arguments"
.to_string(),
});
}
Command::TextAsShapes { text }
}
"nets" => Command::Nets,
"enabled-layers" => Command::EnabledLayers,
"active-layer" => Command::ActiveLayer,
@ -1004,7 +1075,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 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"
"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 text-as-shapes Convert text to rendered shapes\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"
);
}
@ -1427,6 +1498,11 @@ fn proto_coverage_board_read_rows() -> Vec<(&'static str, &'static str, &'static
"implemented",
"get_text_extents_raw/get_text_extents",
),
(
"kiapi.common.commands.GetTextAsShapes",
"implemented",
"get_text_as_shapes_raw/get_text_as_shapes",
),
(
"kiapi.common.commands.GetItems",
"implemented",