Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 78bcdce794 Wire shared compiler into CAD font API 2026-11-15 15:47:14 +00:00
Dylan M. Taylor fd11b47915 Use shared SHP to SHX compiler 2026-11-15 15:47:03 +00:00
8 changed files with 57 additions and 425 deletions
Generated
+6 -19
View File
@@ -714,6 +714,7 @@ version = "0.21.0"
dependencies = [
"cadcraft-geom",
"craft-dxf",
"craft-shx",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
@@ -758,8 +759,6 @@ dependencies = [
"cadcraft-io",
"cadcraft-render",
"clap",
"craft-jsonrpc",
"craft-mcp",
"getrandom 0.4.3",
"libc",
"log",
@@ -1185,23 +1184,6 @@ dependencies = [
"writeable",
]
[[package]]
name = "craft-jsonrpc"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
dependencies = [
"serde_json",
]
[[package]]
name = "craft-mcp"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
dependencies = [
"craft-jsonrpc",
"serde_json",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -1212,6 +1194,11 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-shx"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b3307f7814629961841b80ab907116252e41b34d#b3307f7814629961841b80ab907116252e41b34d"
[[package]]
name = "craft-ui"
version = "0.1.0"
+1 -2
View File
@@ -16,6 +16,7 @@ cadcraft-geom = { path = "crates/geom" }
# DXF tags: the shared reader and writer (moved from crates/dxf; craft-libs#791).
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "006e4df225fca57e95ead0fb1f91920900798a99" }
craft-dwg = { git = "https://github.com/storytold/craft-libs", rev = "7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf" }
craft-shx = { git = "https://github.com/storytold/craft-libs", rev = "b3307f7814629961841b80ab907116252e41b34d" }
cadcraft-color = { path = "crates/color" }
cadcraft-doc = { path = "crates/doc" }
cadcraft-constraints = { path = "crates/constraints" }
@@ -25,8 +26,6 @@ cadcraft-io = { path = "crates/io" }
cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
craft-mcp = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
craft-jsonrpc = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
cadcraft-geom = { workspace = true }
craft-dxf = { workspace = true }
craft-shx = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
-313
View File
@@ -1,313 +0,0 @@
//! Shape source (`.shp`) to a compiled SHX file, as COMPILE does.
//!
//! A source is a list of definitions: a header line `*number,defbytes,NAME` followed by its
//! specification bytes (on as many lines as needed, ending with 0). Numbers are decimal, or
//! hexadecimal after a leading `0` (`012`, `08cfb`); parentheses only group; `;` starts a
//! comment. A big font starts with `*BIGFONT nchars,nranges,first,last,…` and a Unicode font
//! with `*UNIFONT,6,name` followed by `above,below,modes,encoding,type,0`. Shape 0 is a text
//! font's record (`above,below,modes,0`).
//!
//! Operands are encoded by what they are: a subshape number takes two bytes in a Unicode font
//! (and after `7,0` in a big font), displacements and bulges are two's complement, and an arc's
//! direction byte (`-043`) keeps its sign in the high bit. `defbytes` is not checked: the
//! program's own length is written.
use super::ShxError;
use super::parse::{BIGFONT, SHAPES_10, UNIFONT};
/// Largest source compiled.
const MAX_SOURCE: usize = 8 << 20;
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Shapes,
Big,
Uni,
}
struct Shape {
number: u16,
name: String,
program: Vec<u8>,
}
/// A number token: value (sign included).
fn number(tok: &str, line: usize) -> Result<i64, ShxError> {
let t: String = tok.chars().filter(|c| !matches!(c, '(' | ')') && !c.is_whitespace()).collect();
let (neg, digits) = match t.strip_prefix('-') {
Some(d) => (true, d),
None => (false, t.strip_prefix('+').unwrap_or(&t)),
};
let err = || ShxError::Source(line, format!("not a number: {tok:?}"));
let v = if digits.len() > 1 && digits.starts_with('0') {
i64::from_str_radix(digits.get(1..).unwrap_or_default(), 16).map_err(|_| err())?
} else {
digits.parse::<i64>().map_err(|_| err())?
};
if v > 0xFFFF {
return Err(err());
}
Ok(if neg { -v } else { v })
}
/// The comma-separated numbers of a line (comment removed, blank fields skipped).
fn numbers(text: &str, line: usize) -> Result<Vec<i64>, ShxError> {
text.split(',').map(str::trim).filter(|t| !t.is_empty() && *t != "(" && *t != ")").map(|t| number(t, line)).collect()
}
/// The spec-byte encoder: walks the tokens as the reader walks the bytes.
struct Enc<'a> {
toks: &'a [i64],
i: usize,
out: Vec<u8>,
kind: Kind,
line: usize,
}
impl Enc<'_> {
fn next(&mut self) -> Result<i64, ShxError> {
let v = self.toks.get(self.i).copied().ok_or_else(|| ShxError::Source(self.line, "the shape ends inside a command".into()))?;
self.i += 1;
Ok(v)
}
fn byte(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let b = u8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a byte")))?;
self.out.push(b);
Ok(())
}
/// Two's complement −128..127.
fn signed(&mut self) -> Result<i64, ShxError> {
let v = self.next()?;
let b = i8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not −128..127")))?;
self.out.extend(b.to_le_bytes());
Ok(v)
}
/// Sign in the high bit (an arc's direction byte).
fn sign_magnitude(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let m = u8::try_from(v.unsigned_abs())
.ok()
.filter(|m| *m < 0x80)
.ok_or_else(|| ShxError::Source(self.line, format!("{v} is not an octant byte")))?;
self.out.push(if v < 0 { m | 0x80 } else { m });
Ok(())
}
fn word(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let w = u16::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a shape number")))?;
self.out.extend(w.to_be_bytes());
Ok(())
}
fn program(mut self) -> Result<Vec<u8>, ShxError> {
while self.i < self.toks.len() {
let code = self.next()?;
let c = u8::try_from(code).map_err(|_| ShxError::Source(self.line, format!("{code} is not a code")))?;
self.out.push(c);
match c {
0 => break,
3 | 4 => self.byte()?,
7 => match self.kind {
Kind::Uni => self.word()?,
Kind::Big => {
let first = self.toks.get(self.i).copied().unwrap_or(0);
if first == 0 {
self.byte()?;
self.word()?;
for _ in 0..4 {
self.byte()?;
}
} else {
self.byte()?;
}
}
Kind::Shapes => self.byte()?,
},
8 => {
self.signed()?;
self.signed()?;
}
9 => loop {
let (x, y) = (self.signed()?, self.signed()?);
if x == 0 && y == 0 {
break;
}
},
10 => {
self.byte()?;
self.sign_magnitude()?;
}
11 => {
for _ in 0..4 {
self.byte()?;
}
self.sign_magnitude()?;
}
12 => {
for _ in 0..3 {
self.signed()?;
}
}
13 => loop {
let (x, y) = (self.signed()?, self.signed()?);
if x == 0 && y == 0 {
break;
}
self.signed()?;
},
_ => {}
}
}
if self.out.last() != Some(&0) {
self.out.push(0);
}
Ok(self.out)
}
}
/// Compile shape source into an SHX file (a shape file or text font, a big font, or a Unicode
/// font, as its first definition says).
pub fn compile(source: &str) -> Result<Vec<u8>, ShxError> {
if source.len() > MAX_SOURCE {
return Err(ShxError::TooLarge);
}
let mut kind = Kind::Shapes;
let mut ranges: Vec<(u16, u16)> = Vec::new();
let mut uni_info: Option<(String, Vec<u8>)> = None;
// Definitions: header (number, name, line) and the numbers that follow it.
let mut defs: Vec<(i64, String, usize, Vec<i64>)> = Vec::new();
let mut uni_header_pending = false;
for (no, raw) in source.lines().enumerate() {
let line = no + 1;
let text = raw.split(';').next().unwrap_or_default().trim();
if text.is_empty() {
continue;
}
if let Some(head) = text.strip_prefix('*') {
let upper = head.trim_start().to_ascii_uppercase();
if let Some(rest) = upper.strip_prefix("BIGFONT") {
kind = Kind::Big;
let v = numbers(rest, line)?;
let n = usize::try_from(v.get(1).copied().unwrap_or(0)).unwrap_or(0);
for k in 0..n {
let lo = v.get(2 + 2 * k).copied().unwrap_or(0);
let hi = v.get(3 + 2 * k).copied().unwrap_or(lo);
let to = |x: i64| u16::try_from(x).map_err(|_| ShxError::Source(line, "bad escape range".into()));
ranges.push((to(lo)?, to(hi)?));
}
continue;
}
if upper.starts_with("UNIFONT") {
kind = Kind::Uni;
let name = head.splitn(3, ',').nth(2).unwrap_or_default().trim().to_string();
uni_info = Some((name, Vec::new()));
uni_header_pending = true;
continue;
}
uni_header_pending = false;
let mut parts = head.splitn(3, ',');
let num = number(parts.next().unwrap_or_default().trim(), line)?;
let _defbytes = parts.next();
let name = parts.next().unwrap_or_default().trim().to_string();
defs.push((num, name, line, Vec::new()));
continue;
}
let v = numbers(text, line)?;
if uni_header_pending {
if let Some((_, info)) = uni_info.as_mut() {
for x in v {
info.push(u8::try_from(x).map_err(|_| ShxError::Source(line, "bad font record".into()))?);
}
}
continue;
}
match defs.last_mut() {
Some(d) => d.3.extend(v),
None => return Err(ShxError::Source(line, "specification bytes before any *number,defbytes,NAME line".into())),
}
}
let mut shapes: Vec<Shape> = Vec::new();
for (num, name, line, toks) in defs {
let number = u16::try_from(num).map_err(|_| ShxError::Source(line, format!("shape number {num} out of range")))?;
let program = if number == 0 {
// The font record: plain bytes.
toks.iter().map(|x| u8::try_from(*x).map_err(|_| ShxError::Source(line, "bad font record".into()))).collect::<Result<Vec<u8>, _>>()?
} else {
Enc { toks: &toks, i: 0, out: Vec::new(), kind, line }.program()?
};
if shapes.iter().any(|s| s.number == number) {
return Err(ShxError::Source(line, format!("shape {number} is defined twice")));
}
shapes.push(Shape { number, name, program });
}
if shapes.is_empty() {
return Err(ShxError::Empty);
}
shapes.sort_by_key(|s| s.number);
let record = |s: &Shape| -> Result<Vec<u8>, ShxError> {
let mut r: Vec<u8> = s.name.bytes().filter(|b| *b != 0).collect();
r.push(0);
r.extend(&s.program);
if r.len() > usize::from(u16::MAX) { Err(ShxError::TooLarge) } else { Ok(r) }
};
let count = u16::try_from(shapes.len()).map_err(|_| ShxError::TooLarge)?;
let len16 = |r: &[u8]| u16::try_from(r.len()).unwrap_or(u16::MAX);
let mut out = Vec::new();
match kind {
Kind::Shapes => {
out.extend(SHAPES_10);
out.extend(shapes.first().map_or(0, |s| s.number).to_le_bytes());
out.extend(shapes.last().map_or(0, |s| s.number).to_le_bytes());
out.extend(count.to_le_bytes());
let recs: Vec<Vec<u8>> = shapes.iter().map(record).collect::<Result<_, _>>()?;
for (s, r) in shapes.iter().zip(&recs) {
out.extend(s.number.to_le_bytes());
out.extend(len16(r).to_le_bytes());
}
for r in &recs {
out.extend(r);
}
out.extend(b"EOF");
}
Kind::Big => {
out.extend(BIGFONT);
let nranges = u16::try_from(ranges.len()).map_err(|_| ShxError::TooLarge)?;
out.extend(count.to_le_bytes());
out.extend(count.to_le_bytes());
out.extend(nranges.to_le_bytes());
for (lo, hi) in &ranges {
out.extend(lo.to_le_bytes());
out.extend(hi.to_le_bytes());
}
let recs: Vec<Vec<u8>> = shapes.iter().map(record).collect::<Result<_, _>>()?;
let mut off = out.len() + 8 * recs.len();
for (s, r) in shapes.iter().zip(&recs) {
out.extend(s.number.to_le_bytes());
out.extend(len16(r).to_le_bytes());
out.extend(u32::try_from(off).map_err(|_| ShxError::TooLarge)?.to_le_bytes());
off += r.len();
}
for r in &recs {
out.extend(r);
}
}
Kind::Uni => {
out.extend(UNIFONT);
let (name, info) = uni_info.unwrap_or_default();
out.extend((u32::from(count) + 1).to_le_bytes());
let mut rec: Vec<u8> = name.bytes().filter(|b| *b != 0).collect();
rec.push(0);
rec.extend(&info);
out.extend(len16(&rec).to_le_bytes());
out.extend(&rec);
for s in &shapes {
let r = record(s)?;
out.extend(s.number.to_le_bytes());
out.extend(len16(&r).to_le_bytes());
out.extend(r);
}
}
}
Ok(out)
}
+1 -31
View File
@@ -26,7 +26,6 @@
//! above, below, modes, encoding, embedding, 0) and then each shape as (number, length,
//! record).
mod compile;
mod parse;
mod program;
mod table;
@@ -39,7 +38,7 @@ use std::sync::{Arc, PoisonError, RwLock};
use cadcraft_geom::Vec2;
pub use compile::compile;
pub use craft_shx::{ShxError, compile};
pub use table::{
FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, installed, register, search_path, set_font_dirs,
set_font_map, set_provider, set_search_path,
@@ -47,35 +46,6 @@ pub use table::{
pub use text::ShxText;
pub(crate) use text::{shape, width};
/// Why a font file can't be read or a shape source can't be compiled.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ShxError {
/// Not an SHX file (unknown signature).
NotShx,
/// The file ends inside its header or index.
Truncated,
/// The file is larger, or has more of something, than CADCraft reads.
TooLarge,
/// The file is well-formed but holds no shape.
Empty,
/// Shape source error: line and message.
Source(usize, String),
}
impl std::fmt::Display for ShxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShxError::NotShx => f.write_str("not an SHX shape or font file"),
ShxError::Truncated => f.write_str("the SHX file is truncated"),
ShxError::TooLarge => f.write_str("the SHX file is too large"),
ShxError::Empty => f.write_str("the SHX file has no shapes"),
ShxError::Source(line, msg) => write!(f, "line {line}: {msg}"),
}
}
}
impl std::error::Error for ShxError {}
/// The kind of SHX file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShxKind {
-2
View File
@@ -9,8 +9,6 @@ license.workspace = true
workspace = true
[dependencies]
craft-mcp = { workspace = true }
craft-jsonrpc = { workspace = true }
cadcraft-engine = { workspace = true }
cadcraft-render = { workspace = true }
cadcraft-io = { workspace = true }
+48 -37
View File
@@ -22,24 +22,25 @@ Verify with inspect_drawing / query_entities and look with render.";
pub struct Server {
backend: Box<dyn Backend>,
session: craft_mcp::Session,
initialized: bool,
}
fn response(id: Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
}
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self {
backend,
session: craft_mcp::Session::new("cadcraft", env!("CARGO_PKG_VERSION"), craft_mcp::Policy::Compatible)
.with_capabilities(json!({"tools": {"listChanged": false}, "resources": {"listChanged": false}}))
.unwrap_or_else(|_| craft_mcp::Session::new("cadcraft", env!("CARGO_PKG_VERSION"), craft_mcp::Policy::Compatible))
.with_legacy_initialize(SUPPORTED, json!({"name": "cadcraft", "title": "CADCraft", "version": env!("CARGO_PKG_VERSION")})),
}
Self { backend, initialized: false }
}
pub fn backend(&mut self) -> &mut dyn Backend {
self.backend.as_mut()
}
pub fn is_initialized(&self) -> bool {
self.session.is_initialized()
self.initialized
}
/// Serve newline-delimited JSON-RPC until `input` closes (logs to stderr only).
@@ -61,43 +62,53 @@ impl Server {
return None;
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => self.handle_message(&msg),
Err(e) => Some(craft_jsonrpc::error_response(Value::Null, craft_jsonrpc::RpcError::new(PARSE_ERROR, format!("parse error: {e}")))),
// JSON-RPC 2.0: an empty batch is one Invalid Request, not silence.
Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")),
Ok(Value::Array(batch)) => {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
(!replies.is_empty()).then_some(Value::Array(replies))
}
Ok(msg) => self.handle(msg),
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
};
reply.map(|r| r.to_string())
}
pub fn handle(&mut self, msg: Value) -> Option<Value> {
// `handle` historically routes only one envelope; batches belong to `handle_line`.
if msg.is_array() {
return Some(craft_jsonrpc::error_response(Value::Null, craft_jsonrpc::RpcError::new(INVALID_REQUEST, "message must be an object")));
}
self.handle_message(&msg)
}
fn handle_message(&mut self, msg: &Value) -> Option<Value> {
let server = std::cell::RefCell::new(self);
craft_jsonrpc::process_legacy(
msg,
|request| {
let mut server = server.borrow_mut();
if request.method == "initialize" {
let instructions = format!("{INSTRUCTIONS} Backend: {}.", server.backend.describe());
server.session.set_instructions(instructions);
}
if let Some(result) = server.session.request(request, msg.is_array()) {
return result;
}
server
.request(request.method, request.params.unwrap_or(&Value::Null))
.map_err(|(code, message)| craft_jsonrpc::RpcError::new(code, message))
},
|request| server.borrow_mut().session.notification(request),
)
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
let id = o.get("id").cloned();
let Some(method) = o.get("method").and_then(Value::as_str) else {
if o.contains_key("result") || o.contains_key("error") {
return None;
}
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
};
let params = o.get("params").cloned().unwrap_or(Value::Null);
let Some(id) = id else {
if method == "notifications/initialized" {
self.initialized = true;
}
return None;
};
Some(match self.request(method, &params) {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
let version = if SUPPORTED.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
"serverInfo": {"name": "cadcraft", "title": "CADCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(tool_definitions(self.backend.has_ui())),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
-21
View File
@@ -391,24 +391,3 @@ fn render_refuses_unknown_backgrounds_and_plot_styles() {
assert!(crate::Backend::call(&mut h, "ui.render", json!({"background": "purple"})).is_err());
assert!(crate::Backend::call(&mut h, "ui.render", json!({"plot_style": 3})).is_err());
}
#[test]
fn legacy_protocol_routing_and_version_negotiation() {
let mut s = Server::new(Box::new(Headless::default()));
assert!(s.handle_line(r#"{"method":"notifications/initialized"}"#).is_none());
assert!(s.is_initialized());
for (params, expected) in
[(json!(null), "2025-06-18"), (json!({"protocolVersion":"2024-11-05"}), "2024-11-05"), (json!({"protocolVersion":"other"}), "2025-06-18")]
{
let response = call(&mut s, 1, "initialize", params);
assert_eq!(response["result"]["protocolVersion"], expected);
assert_eq!(response["result"]["serverInfo"]["title"], "CADCraft");
assert!(response["result"]["instructions"].as_str().unwrap().contains("Backend:"));
}
let response: Value =
serde_json::from_str(&s.handle_line(r#"[{"id":[1],"method":"ping","params":5},{"method":"ignored"},{"result":1},{}]"#).unwrap()).unwrap();
assert_eq!(
response,
json!([{"jsonrpc":"2.0","id":[1],"result":{}},{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"missing `method`"}}])
);
}