pikru 1.2.0

A pure Rust implementation of pikchr, a PIC-like diagram markup language that generates SVG
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! Core types for pikchr rendering

use crate::ast::TextAttr;
use crate::errors::PikruError;
use crate::types::{BoxIn, EvalValue, Length as Inches, OffsetIn, Point, PtIn, UnitVec};

use super::defaults;
use super::shapes::Shape;

/// Generic numeric value that can be either a length (in inches), a unitless scalar, or a color.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Value {
    Len(Inches),
    Scalar(f64),
    Color(u32), // RGB color packed as 0xRRGGBB
}

impl Value {
    #[allow(dead_code)]
    pub fn as_len(self) -> Result<Inches, PikruError> {
        match self {
            Value::Len(l) => Ok(l),
            Value::Scalar(_) => Err(PikruError::Generic(
                "Expected length value, got scalar".to_string(),
            )),
            Value::Color(_) => Err(PikruError::Generic(
                "Expected length value, got color".to_string(),
            )),
        }
    }

    #[allow(dead_code)]
    pub fn as_scalar(self) -> Result<f64, PikruError> {
        match self {
            Value::Scalar(s) => Ok(s),
            Value::Len(_) => Err(PikruError::Generic(
                "Expected scalar value, got length".to_string(),
            )),
            Value::Color(_) => Err(PikruError::Generic(
                "Expected scalar value, got color".to_string(),
            )),
        }
    }
}

impl From<EvalValue> for Value {
    fn from(ev: EvalValue) -> Self {
        match ev {
            EvalValue::Length(l) => Value::Len(l),
            EvalValue::Scalar(s) => Value::Scalar(s),
            EvalValue::Color(c) => Value::Color(c),
        }
    }
}

impl From<Value> for EvalValue {
    fn from(v: Value) -> Self {
        match v {
            Value::Len(l) => EvalValue::Length(l),
            Value::Scalar(s) => EvalValue::Scalar(s),
            Value::Color(c) => EvalValue::Color(c),
        }
    }
}

/// A point in 2D space
pub type PointIn = PtIn;

/// Bounding box
pub type BoundingBox = BoxIn;

pub fn pin(x: f64, y: f64) -> PointIn {
    Point::new(Inches(x), Inches(y))
}

/// Text with optional positioning and styling attributes
#[derive(Debug, Clone)]
pub struct PositionedText {
    pub value: String,
    pub above: bool,
    pub below: bool,
    pub center: bool,
    pub ljust: bool,
    pub rjust: bool,
    pub bold: bool,
    pub italic: bool,
    pub mono: bool,
    pub big: bool,
    pub small: bool,
    pub xtra: bool,    // Amplify big or small (for double big/small)
    pub aligned: bool, // Rotate text to align with line direction
}

impl PositionedText {
    pub fn new(value: String) -> Self {
        Self {
            value,
            above: false,
            below: false,
            center: false,
            ljust: false,
            rjust: false,
            bold: false,
            italic: false,
            mono: false,
            big: false,
            small: false,
            xtra: false,
            aligned: false,
        }
    }

    pub fn from_textposition(value: String, pos: Option<&crate::ast::TextPosition>) -> Self {
        let mut pt = Self::new(value);
        if let Some(pos) = pos {
            // cref: pik_txt_token (pikchr.c:6262-6265)
            // If we see a second Big or Small, set xtra flag
            for attr in &pos.attrs {
                match attr {
                    TextAttr::Above => pt.above = true,
                    TextAttr::Below => pt.below = true,
                    TextAttr::Center => pt.center = true,
                    TextAttr::LJust => pt.ljust = true,
                    TextAttr::RJust => pt.rjust = true,
                    TextAttr::Bold => pt.bold = true,
                    TextAttr::Italic => pt.italic = true,
                    TextAttr::Mono => pt.mono = true,
                    TextAttr::Big => {
                        if pt.big {
                            // Second occurrence of Big - set xtra
                            pt.xtra = true;
                        } else {
                            pt.big = true;
                        }
                    }
                    TextAttr::Small => {
                        if pt.small {
                            // Second occurrence of Small - set xtra
                            pt.xtra = true;
                        } else {
                            pt.small = true;
                        }
                    }
                    TextAttr::Aligned => pt.aligned = true,
                }
            }
        }
        pt
    }

    /// Font scale factor: 1.25 for big, 0.8 for small, 1.0 otherwise
    /// If xtra is true, square the scale (for double big/small)
    // cref: pik_font_scale (pikchr.c:5065-5071)
    pub fn font_scale(&self) -> f64 {
        let mut scale = 1.0;
        if self.big {
            scale *= 1.25;
        }
        if self.small {
            scale *= 0.8;
        }
        if self.xtra {
            scale *= scale; // Square the scale for double big/small
        }
        scale
    }

    /// Calculate text width in inches, accounting for font properties.
    /// Uses monospace width (82 units/char) or proportional width table.
    /// Applies font scale and bold multiplier.
    // cref: pik_append_txt (pikchr.c:5165-5171)
    pub fn width_inches(&self, charwid: f64) -> f64 {
        let length_hundredths = if self.mono {
            super::monospace_text_length(&self.value)
        } else {
            super::proportional_text_length(&self.value)
        };

        let mut width = length_hundredths as f64 * charwid * self.font_scale() * 0.01;

        // Bold (without mono) text is wider
        if self.bold && !self.mono {
            width *= 1.1;
        }

        width
    }

    /// Height contribution for this text line
    // cref: pik_append_txt (pikchr.c:5107-5108)
    pub fn height(&self, charht: f64) -> f64 {
        self.font_scale() * charht
    }
}

/// A rendered object with its properties
#[derive(Debug, Clone)]
pub struct RenderedObject {
    pub name: Option<String>,
    /// True if the name came from an explicit label (e.g., `C1: circle`),
    /// false if derived from text content (e.g., `circle "C0"`).
    /// cref: pik_find_byname (pikchr.c:4027-4044) - explicit names searched first
    pub name_is_explicit: bool,
    /// Text-derived name for lookup (first text content)
    /// Separate from explicit name so `B1: box "One"` can be found by either "B1" or "One"
    /// cref: pik_find_byname (pikchr.c:4027-4044) - searches text content if explicit not found
    pub text_name: Option<String>,
    pub shape: super::shapes::ShapeEnum,
    pub start_attachment: Option<EndpointObject>,
    pub end_attachment: Option<EndpointObject>,
    /// Layer for z-ordering. Lower layers render first (behind).
    /// Default is 1000. Set via "layer" variable.
    // cref: pik_elem_new (pikchr.c:2960)
    pub layer: i32,
    /// The layout direction when this object was created.
    /// Used to resolve .start and .end edge points.
    // cref: pObj->inDir, pObj->outDir in C pikchr
    pub direction: crate::ast::Direction,
    /// The original class name (Arrow vs Line, etc.)
    /// This is needed for "first arrow" lookups, since arrows are stored as Line shapes
    /// cref: pObj->type in C pikchr
    pub class_name: crate::ast::ClassName,
}

impl RenderedObject {
    /// Translate this object by an offset
    pub fn translate(&mut self, offset: OffsetIn) {
        self.shape.translate(offset);
    }

    /// Calculate edge point in a given direction
    /// For round shapes, diagonal directions use the perimeter (1/√2 factor)
    pub fn edge_point(&self, dir: UnitVec) -> PointIn {
        // Convert UnitVec to EdgeDirection
        use super::shapes::EdgeDirection;
        let edge_dir = if dir == UnitVec::NORTH {
            EdgeDirection::North
        } else if dir == UnitVec::SOUTH {
            EdgeDirection::South
        } else if dir == UnitVec::EAST {
            EdgeDirection::East
        } else if dir == UnitVec::WEST {
            EdgeDirection::West
        } else if dir == UnitVec::NORTH_EAST {
            EdgeDirection::NorthEast
        } else if dir == UnitVec::NORTH_WEST {
            EdgeDirection::NorthWest
        } else if dir == UnitVec::SOUTH_EAST {
            EdgeDirection::SouthEast
        } else if dir == UnitVec::SOUTH_WEST {
            EdgeDirection::SouthWest
        } else {
            EdgeDirection::Center
        };

        self.shape.edge_point(edge_dir)
    }

    // Convenience accessors that delegate to shape
    pub fn center(&self) -> PointIn {
        self.shape.center()
    }

    pub fn width(&self) -> Inches {
        self.shape.width()
    }

    pub fn height(&self) -> Inches {
        self.shape.height()
    }

    pub fn start(&self) -> PointIn {
        self.shape.start()
    }

    pub fn end(&self) -> PointIn {
        self.shape.end()
    }

    pub fn style(&self) -> &ObjectStyle {
        self.shape.style()
    }

    pub fn text(&self) -> &[PositionedText] {
        self.shape.text()
    }

    pub fn waypoints(&self) -> Option<&[PointIn]> {
        self.shape.waypoints()
    }

    pub fn class(&self) -> ClassName {
        self.class_name
    }

    pub fn children(&self) -> Option<&[RenderedObject]> {
        if let super::shapes::ShapeEnum::Sublist(ref s) = self.shape {
            Some(&s.children)
        } else {
            None
        }
    }
}

#[derive(Debug, Clone)]
pub struct EndpointObject {
    pub class: ClassName,
    pub center: PointIn,
    pub width: Inches,
    pub height: Inches,
    pub corner_radius: Inches,
    /// True if this object is inside a sublist (dotted name like Ptr.A)
    /// C pikchr does NOT trigger implicit autochop for dotted names,
    /// but explicit `chop` attribute still works.
    /// cref: pik_position_from_place (pikchr.c) - doesn't set ppObj for dotted names
    pub is_dotted_name: bool,
}

impl EndpointObject {
    pub fn from_rendered(obj: &RenderedObject) -> Self {
        Self {
            class: obj.shape.class(),
            center: obj.shape.center(),
            width: obj.shape.width(),
            height: obj.shape.height(),
            corner_radius: obj.shape.style().corner_radius,
            is_dotted_name: false,
        }
    }

    /// Create from rendered object, marking it as a dotted name (object inside sublist)
    pub fn from_rendered_dotted(obj: &RenderedObject) -> Self {
        Self {
            class: obj.shape.class(),
            center: obj.shape.center(),
            width: obj.shape.width(),
            height: obj.shape.height(),
            corner_radius: obj.shape.style().corner_radius,
            is_dotted_name: true,
        }
    }
}

/// Re-export ClassName as the object class type
pub use crate::ast::ClassName;

impl ClassName {
    /// Returns true if this is a round shape (circle, ellipse, oval)
    pub fn is_round(self) -> bool {
        matches!(self, Self::Circle | Self::Ellipse | Self::Oval)
    }

    /// Diagonal factor for edge point calculations.
    /// Round shapes use 1/√2 so diagonal points land on the perimeter.
    /// Rectangular shapes use 1.0 so diagonal points land on bounding box corners.
    pub fn diagonal_factor(self) -> f64 {
        if self.is_round() {
            std::f64::consts::FRAC_1_SQRT_2
        } else {
            1.0
        }
    }
}

#[derive(Debug, Clone)]
pub struct ObjectStyle {
    pub stroke: String,
    pub fill: String,
    pub stroke_width: Inches,
    /// Dashed line style. Some(width) = dashed with that dash width, None = not dashed.
    /// The width is stored directly from the attribute (e.g., `dashed 0.25` stores 0.25).
    pub dashed: Option<Inches>,
    /// Dotted line style. Some(gap) = dotted with that gap width, None = not dotted.
    pub dotted: Option<Inches>,
    pub arrow_start: bool,
    pub arrow_end: bool,
    pub invisible: bool,
    pub corner_radius: Inches,
    pub chop: bool,
    pub fit: bool,
    pub close_path: bool,
    /// For arcs: true = clockwise, false = counter-clockwise (default)
    pub clockwise: bool,
}

impl Default for ObjectStyle {
    fn default() -> Self {
        Self {
            stroke: "black".to_string(),
            fill: "none".to_string(),
            stroke_width: defaults::STROKE_WIDTH,
            dashed: None,
            dotted: None,
            arrow_start: false,
            arrow_end: false,
            invisible: false,
            corner_radius: Inches::ZERO,
            chop: false,
            fit: false,
            close_path: false,
            clockwise: false,
        }
    }
}