Skip to main content

graphviz_pure_core/
font.rs

1//! Built-in font metrics lookup tables and text/label sizing ported from Graphviz 16.1.0
2//! (`lib/common/textspan_lut.c`, `lib/common/textspan.c`, `lib/common/ps_font_equiv.h`,
3//! and `lib/common/labels.c`).
4
5use crate::charset::{self, Charset};
6use serde::{Deserialize, Serialize};
7
8/// Default font family name in Graphviz (`Times-Roman`).
9pub const DEFAULT_FONT_NAME: &str = "Times-Roman";
10
11/// Default font size in points (`14.0`).
12pub const DEFAULT_FONT_SIZE: f64 = 14.0;
13
14/// Default head/tail label font size in points (`11.0`).
15pub const DEFAULT_LABEL_FONT_SIZE: f64 = 11.0;
16
17/// Minimum allowed font size in points (`1.0`).
18pub const MIN_FONT_SIZE: f64 = 1.0;
19
20/// Line spacing multiplier (`1.20`) from `lib/common/const.h`.
21pub const LINESPACING: f64 = 1.20;
22
23/// Horizontal justification of a single line in a Graphviz label.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub enum TextJustify {
26    /// Centered line (`\n`, default).
27    #[default]
28    Center,
29    /// Left-justified line (`\l`).
30    Left,
31    /// Right-justified line (`\r`).
32    Right,
33}
34
35impl TextJustify {
36    /// Returns the Graphviz justification character (`'n'`, `'l'`, `'r'`).
37    #[inline]
38    pub const fn as_char(self) -> char {
39        match self {
40            Self::Center => 'n',
41            Self::Left => 'l',
42            Self::Right => 'r',
43        }
44    }
45
46    /// Parses a Graphviz justification character (`'n'`, `'l'`, `'r'`).
47    #[inline]
48    pub const fn from_char(c: char) -> Self {
49        match c {
50            'l' => Self::Left,
51            'r' => Self::Right,
52            _ => Self::Center,
53        }
54    }
55}
56
57/// Measured layout of a single line within a multiline text label.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct TextLineLayout {
60    /// Unescaped text content of the line.
61    pub text: String,
62    /// Width of the line in points.
63    pub width: f64,
64    /// Height of the line in points (`font_size * LINESPACING`).
65    pub height: f64,
66    /// Horizontal justification of this line (`\n`, `\l`, `\r`).
67    pub justify: TextJustify,
68}
69
70/// Measured layout of a multiline Graphviz label in points.
71#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
72pub struct TextLabelLayout {
73    /// Measured lines in top-to-bottom order.
74    pub lines: Vec<TextLineLayout>,
75    /// Individual line widths in points (mirrors `lines.iter().map(|l| l.width)`).
76    pub line_widths: Vec<f64>,
77    /// Total bounding width in points (maximum line width).
78    pub width: f64,
79    /// Total bounding height in points (sum of line heights).
80    pub height: f64,
81    /// Font family name used for measurement.
82    pub font_name: String,
83    /// Font size in points used for measurement.
84    pub font_size: f64,
85    /// The label text after object-escape substitution and entity conversion (C's
86    /// `lp->text`), still containing the `\\n`/`\\l`/`\\r` line escapes.
87    #[serde(default)]
88    pub source: String,
89}
90
91/// Context for Graphviz object-based escape sequence substitution (`\N`, `\G`, `\T`, `\H`, `\E`, `\L`).
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
93pub struct LabelSubstContext<'a> {
94    pub node_name: &'a str,
95    pub graph_name: &'a str,
96    pub tail_name: &'a str,
97    pub head_name: &'a str,
98    pub is_edge: bool,
99    pub is_directed: bool,
100    pub tail_port: Option<&'a str>,
101    pub head_port: Option<&'a str>,
102    pub label_text: &'a str,
103    /// `getPenColor(obj)` (`pencolor`, else `color`), which an HTML label's outermost
104    /// table without a `COLOR` takes (`make_html_label`).
105    pub pen_color: Option<&'a str>,
106    /// The object is a node (its name may be empty).
107    pub is_node: bool,
108    /// The object has a main label (`ND_label`, `ED_label`, `GD_label`), whose text is
109    /// `label_text` even when empty; without one `\L` stays literal.
110    pub has_label: bool,
111}
112
113impl<'a> LabelSubstContext<'a> {
114    /// Creates a substitution context for a node with the given name.
115    #[inline]
116    pub const fn for_node(node_name: &'a str) -> Self {
117        Self {
118            node_name,
119            graph_name: "",
120            tail_name: "",
121            head_name: "",
122            is_edge: false,
123            is_directed: true,
124            tail_port: None,
125            head_port: None,
126            label_text: "",
127            pen_color: None,
128            is_node: true,
129            has_label: false,
130        }
131    }
132}
133
134/// Font family character width lookup table (`struct FontFamilyMetrics` in
135/// `lib/common/textspan_lut.c`).
136pub(crate) struct FontFamilyMetrics {
137    /// Case-insensitive list of metrically-equivalent family names.
138    pub(crate) font_names: &'static [&'static str],
139    /// Widths are stored in `(units_per_em * 1)` pt.
140    pub(crate) units_per_em: f64,
141    pub(crate) widths_regular: [i16; 128],
142    pub(crate) widths_bold: [i16; 128],
143    pub(crate) widths_italic: [i16; 128],
144    pub(crate) widths_bold_italic: [i16; 128],
145}
146
147/// PostScript font alias entry (`PostscriptAlias` in `lib/common/textspan.h`).
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct PostscriptFontAlias {
150    pub name: &'static str,
151    pub family: &'static str,
152    pub weight: Option<&'static str>,
153    pub stretch: Option<&'static str>,
154    pub style: Option<&'static str>,
155    pub xfig_code: i32,
156    pub svg_font_family: &'static str,
157    pub svg_font_weight: Option<&'static str>,
158    pub svg_font_style: Option<&'static str>,
159}
160
161#[rustfmt::skip]
162#[path = "font_tables.rs"]
163mod font_tables;
164
165/// Standard PostScript font equivalence table (`postscript_alias[]` from `lib/common/textspan.c`,
166/// generated from `ps_font_equiv.h` by `scripts/gen-font-tables.py`).
167pub const POSTSCRIPT_FONT_ALIASES: &[PostscriptFontAlias] = &font_tables::POSTSCRIPT_ALIASES;
168
169/// C `strcasecmp` ordering (ASCII case folding, byte comparison).
170fn strcasecmp(a: &str, b: &str) -> std::cmp::Ordering {
171    let la = a.bytes().map(|c| c.to_ascii_lowercase());
172    let lb = b.bytes().map(|c| c.to_ascii_lowercase());
173    la.cmp(lb)
174}
175
176/// `translate_postscript_fontname` from `lib/common/textspan.c`: `bsearch` of the alias table
177/// with `strcasecmp`. The name is used verbatim (no trimming), as in C.
178pub fn translate_postscript_fontname(font_name: &str) -> Option<&'static PostscriptFontAlias> {
179    POSTSCRIPT_FONT_ALIASES
180        .binary_search_by(|alias| strcasecmp(alias.name, font_name))
181        .ok()
182        .map(|i| &POSTSCRIPT_FONT_ALIASES[i])
183}
184
185/// `font_name_equal_permissive` from `lib/common/textspan_lut.c`: compares two font names
186/// ignoring case and every character that is not an ASCII letter.
187fn font_name_equal_permissive(a: &str, b: &str) -> bool {
188    let a_iter = a
189        .bytes()
190        .filter(u8::is_ascii_alphabetic)
191        .map(|c| c.to_ascii_lowercase());
192    let b_iter = b
193        .bytes()
194        .filter(u8::is_ascii_alphabetic)
195        .map(|c| c.to_ascii_lowercase());
196    a_iter.eq(b_iter)
197}
198
199/// `font_in_list_permissive` from `lib/common/textspan_lut.c`.
200fn font_in_list_permissive(value: &str, list: &[&str]) -> bool {
201    list.iter()
202        .any(|entry| font_name_equal_permissive(value, entry))
203}
204
205/// `get_metrics_for_font_family` from `lib/common/textspan_lut.c`: the first family whose
206/// name list matches, else the `Times` metrics (C emits a once-only warning).
207fn get_metrics_for_font_family(font_name: &str) -> &'static FontFamilyMetrics {
208    font_tables::FONT_METRICS
209        .iter()
210        .find(|m| font_in_list_permissive(font_name, m.font_names))
211        .unwrap_or_else(|| {
212            let warning = format!(
213                "Warning: no hard-coded metrics for '{font_name}'.  Falling back to 'Times' metrics\n"
214            );
215            // A quiet repeat leaves the warning to the measurement C makes.
216            if crate::diag::reporting() && crate::diag::emit_once(&warning) {
217                crate::diag::agwarningf(&warning);
218            }
219            &font_tables::FONT_METRICS[0]
220        })
221}
222
223/// `get_metrics_for_font_variant` from `lib/common/textspan_lut.c`.
224fn get_metrics_for_font_variant(
225    family_metrics: &'static FontFamilyMetrics,
226    bold: bool,
227    italic: bool,
228) -> &'static [i16; 128] {
229    if bold && italic {
230        &family_metrics.widths_bold_italic
231    } else if bold {
232        &family_metrics.widths_bold
233    } else if italic {
234        &family_metrics.widths_italic
235    } else {
236        &family_metrics.widths_regular
237    }
238}
239
240/// `estimate_character_width_canonical` from `lib/common/textspan_lut.c`: width of one byte in
241/// `(units_per_em * 1)` points. Bytes `>= 128` (every byte of a UTF-8 multi-byte sequence) use
242/// the width of `' '`; unknown ASCII widths (`-1`) count as `0`; each case warns once per process.
243#[inline]
244fn estimate_character_width_canonical(variant_metrics: &[i16; 128], byte: u8) -> u16 {
245    use std::sync::atomic::{AtomicBool, Ordering};
246    static NON_ASCII_WARNED: AtomicBool = AtomicBool::new(false);
247    static NO_WIDTH_WARNED: AtomicBool = AtomicBool::new(false);
248    let character = if byte >= 128 {
249        if crate::diag::reporting() && !NON_ASCII_WARNED.swap(true, Ordering::Relaxed) {
250            eprintln!(
251                "Warning: Warning: no value for width of non-ASCII character {byte}. Falling \
252                 back to width of space character"
253            );
254        }
255        b' '
256    } else {
257        byte
258    };
259    let width = variant_metrics[usize::from(character)];
260    if width == -1 {
261        if crate::diag::reporting() && !NO_WIDTH_WARNED.swap(true, Ordering::Relaxed) {
262            eprintln!(
263                "Warning: Warning: no value for width of ASCII character {character}. Falling \
264                 back to 0"
265            );
266        }
267        0
268    } else {
269        width as u16
270    }
271}
272
273/// `estimate_text_width_1pt` from `lib/common/textspan_lut.c`: width of `text` at 1pt.
274///
275/// Matches C exactly: the font name is looked up directly in the LUT families (PostScript
276/// aliases such as `Times-Bold` are *not* consulted, so they measure as regular Times), and
277/// the text is measured byte by byte, so each byte of a UTF-8 multi-byte character counts as
278/// one space (see `docs/c-bugs-ledger.md`, GV-BUG-011/013).
279pub fn estimate_text_width_1pt(font_name: &str, text: &str, bold: bool, italic: bool) -> f64 {
280    let family_metrics = get_metrics_for_font_family(font_name);
281    let variant_metrics = get_metrics_for_font_variant(family_metrics, bold, italic);
282    let text_width_canonical: u32 = charset::encode(text)
283        .iter()
284        .map(|&b| u32::from(estimate_character_width_canonical(variant_metrics, b)))
285        .sum();
286    f64::from(text_width_canonical) / family_metrics.units_per_em
287}
288
289/// `htmlEntity` from `lib/common/utils.c`. `s` starts at the byte after `&`. On success returns
290/// `(value, consumed)`. Like C, a numeric reference terminated by `;` is consumed even when
291/// its value is `0` (so `&#0;` collapses to `&`); unknown names consume nothing.
292fn html_entity(s: &[u8]) -> (u32, usize) {
293    let at = |i: usize| s.get(i).copied().unwrap_or(0);
294    if at(0) == b'#' {
295        let mut n: u32 = 0;
296        let mut byte;
297        let mut i;
298        if at(1) == b'x' || at(1) == b'X' {
299            i = 2;
300            loop {
301                byte = at(i);
302                if i >= 8 {
303                    break;
304                }
305                let d = match byte {
306                    b'A'..=b'F' => byte - b'A' + 10,
307                    b'a'..=b'f' => byte - b'a' + 10,
308                    b'0'..=b'9' => byte - b'0',
309                    _ => break,
310                };
311                n = n.wrapping_mul(16).wrapping_add(u32::from(d));
312                i += 1;
313            }
314        } else {
315            i = 1;
316            loop {
317                byte = at(i);
318                if i >= 8 {
319                    break;
320                }
321                if byte.is_ascii_digit() {
322                    n = n.wrapping_mul(10).wrapping_add(u32::from(byte - b'0'));
323                } else {
324                    break;
325                }
326                i += 1;
327            }
328        }
329        // C checks the last byte it examined; when the loop ran to completion `byte` is
330        // the final digit, which can never be ';'.
331        if i < 8 && byte == b';' {
332            (n, i + 1)
333        } else {
334            (0, 0)
335        }
336    } else {
337        for i in 0..font_tables::ENTITY_NAME_LENGTH_MAX {
338            let byte = at(i);
339            if byte == 0 {
340                break;
341            }
342            if byte == b';' {
343                let key = &s[..i];
344                if let Ok(idx) = font_tables::HTML_ENTITIES
345                    .binary_search_by(|(name, _)| name.as_bytes().cmp(key))
346                {
347                    return (font_tables::HTML_ENTITIES[idx].1, i + 1);
348                }
349                break;
350            }
351        }
352        (0, 0)
353    }
354}
355
356/// Appends the C encoding of entity value `v` (`htmlEntityUTF8`/`latin1ToUTF8`: 1, 2 or 3
357/// bytes, never 4).
358fn push_entity_bytes(out: &mut Vec<u8>, v: u32) {
359    if v < 0x7F {
360        out.push(v as u8);
361    } else if v < 0x07FF {
362        out.push(((v >> 6) | 0xC0) as u8);
363        out.push(((v & 0x3F) | 0x80) as u8);
364    } else {
365        out.push(((v >> 12) | 0xE0) as u8);
366        out.push((((v >> 6) & 0x3F) | 0x80) as u8);
367        out.push(((v & 0x3F) | 0x80) as u8);
368    }
369}
370
371/// `htmlEntityUTF8` from `lib/common/utils.c`: replaces HTML entities (`&amp;`, `&#123;`,
372/// `&#x7B;`) by their UTF-8 encoding. Works on the input's bytes (see [`crate::charset`]):
373/// a lead byte without its continuation bytes, or a byte `>= 0xF8`, is converted as if it
374/// were Latin-1, with C's once-per-graph warning.
375pub fn html_entity_utf8(text: &str) -> String {
376    if !text.contains('&') && !charset::has_raw(text) {
377        return text.to_string();
378    }
379    let s = charset::encode(text);
380    let s: &[u8] = &s;
381    let at = |i: usize| s.get(i).copied().unwrap_or(0);
382    let mut out = Vec::with_capacity(s.len());
383    let mut i = 0;
384    while i < s.len() {
385        let mut c = s[i];
386        i += 1;
387        let uc: i32 = if c < 0xC0 {
388            0
389        } else if c < 0xE0 {
390            1
391        } else if c < 0xF0 {
392            2
393        } else if c < 0xF8 {
394            3
395        } else {
396            charset::warn_invalid_utf8(|g| {
397                format!(
398                    "Warning: UTF8 codes > 4 bytes are not currently supported (graph {g}) - \
399                     treated as Latin-1. Perhaps \"-Gcharset=latin1\" is needed?\n"
400                )
401            });
402            c = cvt_and_append(c, &mut out);
403            -1
404        };
405        if uc == 0 && c == b'&' {
406            let (v, used) = html_entity(&s[i..]);
407            i += used;
408            if v != 0 {
409                if v < 0x7F {
410                    c = v as u8;
411                } else if v < 0x07FF {
412                    out.push(((v >> 6) | 0xC0) as u8);
413                    c = ((v & 0x3F) | 0x80) as u8;
414                } else {
415                    out.push(((v >> 12) | 0xE0) as u8);
416                    out.push((((v >> 6) & 0x3F) | 0x80) as u8);
417                    c = ((v & 0x3F) | 0x80) as u8;
418                }
419            }
420        } else {
421            for _ in 0..uc.max(0) {
422                if at(i) & 0xC0 == 0x80 {
423                    out.push(c);
424                    c = at(i);
425                    i += 1;
426                } else {
427                    charset::warn_invalid_utf8(|g| {
428                        format!(
429                            "Warning: Invalid {}-byte UTF8 found in input of graph {g} - \
430                             treated as Latin-1. Perhaps \"-Gcharset=latin1\" is needed?\n",
431                            uc + 1
432                        )
433                    });
434                    c = cvt_and_append(c, &mut out);
435                    break;
436                }
437            }
438        }
439        out.push(c);
440    }
441    charset::decode(out)
442}
443
444/// `cvtAndAppend`: appends all but the last byte of the Latin-1-to-UTF-8 conversion of `c`
445/// and returns the last one.
446fn cvt_and_append(c: u8, out: &mut Vec<u8>) -> u8 {
447    let mut conv = Vec::with_capacity(3);
448    push_entity_bytes(&mut conv, u32::from(c));
449    let last = conv.pop().unwrap_or(c);
450    out.extend_from_slice(&conv);
451    last
452}
453
454/// `latin1ToUTF8` from `lib/common/utils.c` (used when `charset=latin1`): translates HTML
455/// entities and re-encodes every byte `>= 0x7F` of the input (see [`crate::charset`]) as
456/// if it were a Latin-1 code point.
457pub fn latin1_to_utf8(text: &str) -> String {
458    let s = charset::encode(text);
459    let s: &[u8] = &s;
460    let mut out = Vec::with_capacity(s.len());
461    let mut i = 0;
462    while i < s.len() {
463        let mut v = u32::from(s[i]);
464        i += 1;
465        if v == u32::from(b'&') {
466            let (ev, used) = html_entity(&s[i..]);
467            i += used;
468            v = if ev == 0 { u32::from(b'&') } else { ev };
469        }
470        push_entity_bytes(&mut out, v);
471    }
472    charset::decode(out)
473}
474
475/// The label text conversion of `make_label` for a graph of charset `cs`
476/// (`latin1ToUTF8` for Latin-1, `htmlEntityUTF8` otherwise).
477pub fn convert_label_text(text: &str, cs: Charset) -> String {
478    match cs {
479        Charset::Latin1 => latin1_to_utf8(text),
480        Charset::Utf8 | Charset::Big5 => html_entity_utf8(text),
481    }
482}
483
484/// Decodes HTML character entity references, matching `htmlEntityUTF8` in
485/// `lib/common/utils.c` (kept under its historical name for existing callers).
486pub fn decode_html_entities(text: &str) -> String {
487    html_entity_utf8(text)
488}
489
490/// Estimates the `(width, height)` in points of a single-line text span for a given
491/// `font_name` and `font_size`, matching `estimate_textspan_size` in `lib/common/textspan.c`.
492pub fn estimate_text_size(text: &str, font_name: &str, font_size: f64) -> (f64, f64) {
493    estimate_text_size_styled(text, font_name, font_size, false, false)
494}
495
496/// `estimate_textspan_size` from `lib/common/textspan.c` with explicit `bold`/`italic` flags
497/// (`HTML_BF`/`HTML_IF`): `(fontsize * width_1pt, fontsize * LINESPACING)`.
498pub fn estimate_text_size_styled(
499    text: &str,
500    font_name: &str,
501    font_size: f64,
502    bold: bool,
503    italic: bool,
504) -> (f64, f64) {
505    let width = font_size * estimate_text_width_1pt(font_name, text, bold, italic);
506    let height = font_size * LINESPACING;
507    (width, height)
508}
509
510/// Kind of graph object a label belongs to, deciding which `strdup_and_subst_obj0`
511/// substitutions are available.
512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
513enum SubstObjKind {
514    Graph,
515    Node,
516    Edge,
517}
518
519impl LabelSubstContext<'_> {
520    fn obj_kind(&self) -> SubstObjKind {
521        if self.is_edge {
522            SubstObjKind::Edge
523        } else if self.is_node || !self.node_name.is_empty() {
524            SubstObjKind::Node
525        } else {
526            SubstObjKind::Graph
527        }
528    }
529}
530
531/// `strdup_and_subst_obj0` from `lib/common/labels.c`.
532///
533/// Replaces `\G`, `\N`, `\E`, `\H`, `\T`, `\L` with the names of the associated objects.
534/// Escapes that do not apply to the object kind stay literal (e.g. `\T` in a node label stays
535/// `\T`, which `make_simple_label` later reduces to `T`), and `\L` stays literal when
536/// `label_text` is empty (no label exists yet). When `esc_backslash` is set `\\` becomes `\`;
537/// every other `\c` dyad is passed through unchanged.
538fn strdup_and_subst_obj0(s: &str, ctx: &LabelSubstContext<'_>, esc_backslash: bool) -> String {
539    if !s.contains('\\') {
540        return s.to_string();
541    }
542    let kind = ctx.obj_kind();
543    let g_str = ctx.graph_name;
544    let n_str = if kind == SubstObjKind::Node {
545        ctx.node_name
546    } else {
547        "\\N"
548    };
549    let (t_str, h_str) = if kind == SubstObjKind::Edge {
550        (ctx.tail_name, ctx.head_name)
551    } else {
552        ("\\T", "\\H")
553    };
554    let l_str = if !ctx.has_label && ctx.label_text.is_empty() {
555        "\\L"
556    } else {
557        ctx.label_text
558    };
559    let mut out = String::with_capacity(s.len());
560    let mut chars = s.chars();
561    while let Some(c) = chars.next() {
562        if c != '\\' {
563            out.push(c);
564            continue;
565        }
566        let Some(next) = chars.clone().next() else {
567            out.push('\\');
568            continue;
569        };
570        chars.next();
571        match next {
572            'G' => out.push_str(g_str),
573            'N' => out.push_str(n_str),
574            'E' => {
575                if kind == SubstObjKind::Edge {
576                    out.push_str(t_str);
577                    if let Some(tp) = ctx.tail_port.filter(|p| !p.is_empty()) {
578                        out.push(':');
579                        out.push_str(tp);
580                    }
581                    out.push_str(if ctx.is_directed { "->" } else { "--" });
582                    out.push_str(h_str);
583                    if let Some(hp) = ctx.head_port.filter(|p| !p.is_empty()) {
584                        out.push(':');
585                        out.push_str(hp);
586                    }
587                }
588            }
589            'T' => out.push_str(t_str),
590            'H' => out.push_str(h_str),
591            'L' => out.push_str(l_str),
592            '\\' if esc_backslash => out.push('\\'),
593            other => {
594                out.push('\\');
595                out.push(other);
596            }
597        }
598    }
599    out
600}
601
602/// Substitutes Graphviz object escapes (`\N`, `\G`, `\T`, `\H`, `\E`, `\L`) while preserving
603/// line-formatting escapes (`\n`, `\l`, `\r`, `\\`) for `make_simple_label`; this is
604/// `strdup_and_subst_obj0(str, obj, 0)` from `lib/common/labels.c`.
605pub fn substitute_object_escapes(label: &str, ctx: &LabelSubstContext<'_>) -> String {
606    strdup_and_subst_obj0(label, ctx, false)
607}
608
609/// `strdup_and_subst_obj` from `lib/common/labels.c`: object escapes plus `\\` -> `\`
610/// (used for `URL`, `tooltip`, `id`, ... attributes).
611pub fn strdup_and_subst_obj(s: &str, ctx: &LabelSubstContext<'_>) -> String {
612    strdup_and_subst_obj0(s, ctx, true)
613}
614
615/// `storeline` from `lib/common/labels.c`: measures one line and accumulates it into the
616/// label. Empty lines get height `(int)(fontsize * LINESPACING)` (C truncates; non-empty
617/// lines do not), see GV-BUG-012 in `docs/c-bugs-ledger.md`.
618fn storeline(layout: &mut TextLabelLayout, line: String, terminator: TextJustify) {
619    let (width, height) = if line.is_empty() {
620        (0.0, (layout.font_size * LINESPACING).trunc())
621    } else {
622        estimate_text_size(&line, &layout.font_name, layout.font_size)
623    };
624    layout.width = layout.width.max(width);
625    layout.height += height;
626    layout.line_widths.push(width);
627    layout.lines.push(TextLineLayout {
628        text: line,
629        width,
630        height,
631        justify: terminator,
632    });
633}
634
635/// `make_simple_label` from `lib/common/labels.c`: splits `text` (already object- and
636/// entity-substituted) into lines on `\n`, `\l`, `\r` and literal newlines, reducing every
637/// other `\c` to `c`. `width`/`height` are C's `lp->dimen`.
638pub fn make_simple_label(text: &str, font_name: &str, font_size: f64) -> TextLabelLayout {
639    make_simple_label_cs(text, font_name, font_size, Charset::Utf8)
640}
641
642/// [`make_simple_label`] for a graph of charset `cs`. C scans bytes; with `charset=big5` a
643/// byte in `0xA1..=0xFE` takes the next byte with it, so that byte is never an escape.
644pub fn make_simple_label_cs(
645    text: &str,
646    font_name: &str,
647    font_size: f64,
648    cs: Charset,
649) -> TextLabelLayout {
650    let mut layout = TextLabelLayout {
651        font_name: font_name.to_string(),
652        font_size,
653        ..TextLabelLayout::default()
654    };
655    if text.is_empty() {
656        return layout;
657    }
658    let bytes = charset::encode(text);
659    let s: &[u8] = &bytes;
660    let big5 = cs == Charset::Big5;
661    let mut line: Vec<u8> = Vec::new();
662    // C appends the NUL after a trailing backslash, which makes the buffer non-empty without
663    // adding visible text.
664    let mut pending_nul = false;
665    let take = |line: &mut Vec<u8>| charset::decode(std::mem::take(line));
666    let mut i = 0;
667    while i < s.len() {
668        let c = s[i];
669        i += 1;
670        if big5 && (0xA1..=0xFE).contains(&c) {
671            line.push(c);
672            match s.get(i) {
673                Some(&n) => {
674                    line.push(n);
675                    i += 1;
676                }
677                // NB. C protects against an unexpected end here.
678                None => break,
679            }
680        } else if c == b'\\' {
681            match s.get(i) {
682                Some(&j @ (b'n' | b'l' | b'r')) => {
683                    i += 1;
684                    storeline(
685                        &mut layout,
686                        take(&mut line),
687                        TextJustify::from_char(char::from(j)),
688                    );
689                    pending_nul = false;
690                }
691                Some(&other) => {
692                    i += 1;
693                    if !pending_nul {
694                        line.push(other);
695                    }
696                }
697                None => pending_nul = true,
698            }
699        } else if c == b'\n' {
700            storeline(&mut layout, take(&mut line), TextJustify::Center);
701            pending_nul = false;
702        } else if !pending_nul {
703            line.push(c);
704        }
705    }
706    if !line.is_empty() || pending_nul {
707        storeline(&mut layout, take(&mut line), TextJustify::Center);
708    }
709    layout
710}
711
712/// Plain-text branch of `make_label` from `lib/common/labels.c`: object escape substitution,
713/// charset conversion (`latin1ToUTF8` for `charset=latin1`, else `htmlEntityUTF8`) and
714/// `make_simple_label`.
715pub fn make_text_label(
716    label: &str,
717    ctx: &LabelSubstContext<'_>,
718    font_name: &str,
719    font_size: f64,
720    cs: Charset,
721) -> TextLabelLayout {
722    // `\L` is the object's main label (`ND_label(n)->text`, ...): callers pass an empty
723    // `label_text` while that label itself is being made, which leaves `\L` literal.
724    let substituted = strdup_and_subst_obj0(label, ctx, false);
725    let converted = convert_label_text(&substituted, cs);
726    let mut layout = make_simple_label_cs(&converted, font_name, font_size, cs);
727    layout.source = converted;
728    layout
729}
730
731/// Returns `true` if a graph's `charset` attribute selects Latin-1 (`findCharset` in
732/// `lib/common/input.c`: `latin-1`, `latin1`, `l1`, `ISO-8859-1`, `ISO_8859-1`, `ISO8859-1`,
733/// `ISO-IR-100`, case-insensitively).
734pub fn charset_is_latin1(charset: Option<&str>) -> bool {
735    Charset::from_attr(charset) == Charset::Latin1
736}
737
738/// Measures a multiline Graphviz label for a node named `node_name`.
739pub fn measure_multiline_label(
740    label: &str,
741    node_name: &str,
742    font_name: &str,
743    font_size: f64,
744) -> TextLabelLayout {
745    let ctx = LabelSubstContext::for_node(node_name);
746    measure_multiline_label_with_context(label, &ctx, font_name, font_size)
747}
748
749/// Measures a multiline plain-text Graphviz label in the charset of the graph being laid
750/// out ([`charset::current`]); see [`make_text_label`].
751pub fn measure_multiline_label_with_context(
752    label: &str,
753    ctx: &LabelSubstContext<'_>,
754    font_name: &str,
755    font_size: f64,
756) -> TextLabelLayout {
757    make_text_label(label, ctx, font_name, font_size, charset::current())
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    #[test]
765    fn test_exact_character_widths_times_helvetica_courier() {
766        // Times-Roman: 'A' is index 65 -> width 1479 / 2048
767        let w_times_a = estimate_text_width_1pt("Times-Roman", "A", false, false);
768        assert!((w_times_a - 1479.0 / 2048.0).abs() < 1e-12);
769
770        // C looks the name up directly in the LUT families: "Times-Bold" and "Times-Italic"
771        // are not family names, so they fall back to regular Times (verified against
772        // `dot -Txdot` 16.1.0: `AAAA` in Times-Bold 14pt is 40.44pt wide, as in Times-Roman).
773        let w_times_bold_a = estimate_text_width_1pt("Times-Bold", "A", false, false);
774        assert!((w_times_bold_a - w_times_a).abs() < 1e-12);
775        let w_times_italic_a = estimate_text_width_1pt("Times", "A", false, true);
776        assert!((w_times_italic_a - 1251.0 / 2048.0).abs() < 1e-12);
777
778        // Helvetica: 'A' is index 65 -> width 1366 / 2048
779        let w_helv_a = estimate_text_width_1pt("Helvetica", "A", false, false);
780        assert!((w_helv_a - 1366.0 / 2048.0).abs() < 1e-12);
781
782        // "Helvetica-Bold" is not a family name either: Times fallback.
783        let w_helv_bold_a = estimate_text_width_1pt("Helvetica-Bold", "A", false, false);
784        assert!((w_helv_bold_a - w_times_a).abs() < 1e-12);
785        let w_helv_bold_a = estimate_text_width_1pt("Helvetica", "A", true, false);
786        assert!((w_helv_bold_a - 1479.0 / 2048.0).abs() < 1e-12);
787
788        // Courier and Courier-Bold: monospace 1229 / 2048 per printable ASCII char
789        let w_cour = estimate_text_width_1pt("Courier", "Hello", false, false);
790        assert!((w_cour - (5.0 * 1229.0 / 2048.0)).abs() < 1e-12);
791        let w_cour_bold = estimate_text_width_1pt("Courier New", "Hello", true, false);
792        assert!((w_cour_bold - (5.0 * 1229.0 / 2048.0)).abs() < 1e-12);
793
794        // Symbol falls back to Times metrics
795        let w_symbol_a = estimate_text_width_1pt("Symbol", "A", false, false);
796        assert!((w_symbol_a - w_times_a).abs() < 1e-12);
797    }
798
799    #[test]
800    fn test_estimate_text_size_points() {
801        let (w, h) = estimate_text_size("abc", "Courier", 14.0);
802        let expected_w = 14.0 * (3.0 * 1229.0 / 2048.0);
803        let expected_h = 14.0 * 1.20;
804        assert!((w - expected_w).abs() < 1e-9);
805        assert!((h - expected_h).abs() < 1e-9);
806    }
807
808    #[test]
809    fn test_measure_multiline_label_escapes_and_justifications() {
810        let layout =
811            measure_multiline_label("\\N\\nline two\\lright\\r", "node_1", "Courier", 10.0);
812        assert_eq!(layout.lines.len(), 3);
813        assert_eq!(layout.line_widths.len(), 3);
814
815        assert_eq!(layout.lines[0].text, "node_1");
816        assert_eq!(layout.lines[0].justify, TextJustify::Center);
817
818        assert_eq!(layout.lines[1].text, "line two");
819        assert_eq!(layout.lines[1].justify, TextJustify::Left);
820
821        assert_eq!(layout.lines[2].text, "right");
822        assert_eq!(layout.lines[2].justify, TextJustify::Right);
823
824        // Max width is "line two" (8 chars)
825        let char_w = 10.0 * (1229.0 / 2048.0);
826        assert!((layout.line_widths[0] - 6.0 * char_w).abs() < 1e-9);
827        assert!((layout.line_widths[1] - 8.0 * char_w).abs() < 1e-9);
828        assert!((layout.line_widths[2] - 5.0 * char_w).abs() < 1e-9);
829        assert!((layout.width - 8.0 * char_w).abs() < 1e-9);
830        assert!((layout.height - 3.0 * 10.0 * LINESPACING).abs() < 1e-9);
831    }
832
833    #[test]
834    fn test_measure_multiline_label_context_escapes() {
835        let ctx = LabelSubstContext {
836            node_name: "n1",
837            graph_name: "G_main",
838            tail_name: "src",
839            head_name: "dst",
840            is_edge: true,
841            is_directed: true,
842            tail_port: None,
843            head_port: None,
844            label_text: "",
845            pen_color: None,
846            is_node: false,
847            has_label: false,
848        };
849        let layout = measure_multiline_label_with_context(
850            "\\G: \\T -> \\H (\\E)",
851            &ctx,
852            "Times-Roman",
853            14.0,
854        );
855        assert_eq!(layout.lines.len(), 1);
856        assert_eq!(layout.lines[0].text, "G_main: src -> dst (src->dst)");
857    }
858
859    #[test]
860    fn utf8_is_measured_per_byte_like_c() {
861        // `dot -Txdot`: "é€x" in Times-Roman 14pt is 24.5pt (5 non-ASCII bytes as spaces + x).
862        let (w, _) = estimate_text_size("é€x", "Times-Roman", 14.0);
863        assert!((w - 24.5).abs() < 1e-9);
864    }
865
866    #[test]
867    fn empty_line_height_is_truncated_like_c() {
868        let l = make_simple_label("a\\n\\nb", "Times-Roman", 14.0);
869        assert_eq!(l.lines.len(), 3);
870        assert!((l.lines[1].height - 16.0).abs() < 1e-12);
871        assert!((l.height - (16.8 + 16.0 + 16.8)).abs() < 1e-9);
872    }
873
874    #[test]
875    fn html_entities_follow_c_rules() {
876        assert_eq!(html_entity_utf8("a&amp;b"), "a&b");
877        assert_eq!(html_entity_utf8("&#65;&#x42;"), "AB");
878        assert_eq!(html_entity_utf8("&bogus;"), "&bogus;");
879        assert_eq!(html_entity_utf8("&#0;x"), "&x");
880        assert_eq!(html_entity_utf8("&alpha;"), "\u{3b1}");
881    }
882
883    #[test]
884    fn charset_conversions_run_on_input_bytes() {
885        let raw = |b: &[u8]| charset::decode(b.to_vec());
886        // Latin-1 bytes are converted once; UTF-8 bytes under charset=latin1 twice, as in C.
887        assert_eq!(
888            latin1_to_utf8(&raw(b"caf\xe9 &eacute;")),
889            "caf\u{e9} \u{e9}"
890        );
891        assert_eq!(latin1_to_utf8("\u{e9}"), "\u{c3}\u{a9}");
892        // htmlEntityUTF8 treats a lead byte without continuation bytes as Latin-1.
893        assert_eq!(html_entity_utf8(&raw(b"caf\xe9 x")), "caf\u{e9} x");
894        assert_eq!(html_entity_utf8(&raw(b"\xff")), "\u{ff}");
895        // A 3-byte lead with one continuation byte keeps both, then converts the next one:
896        // the result is not valid UTF-8, like C's.
897        let out = html_entity_utf8(&raw(b"\xe4\xbcz"));
898        assert_eq!(&*charset::encode(&out), b"\xe4\xc2\xbcz");
899        // Width is measured per input byte: one raw byte is one space wide.
900        let w = |t: &str| estimate_text_width_1pt("Times-Roman", t, false, false);
901        assert_eq!(w(&raw(b"\xa4")), w(" "));
902    }
903
904    #[test]
905    fn big5_pairs_bytes_in_make_simple_label() {
906        // E4 B8 AD: E4 takes B8, AD takes the backslash, so "\n" is not a line break.
907        let l = make_simple_label_cs("\u{4e2d}\\nX\\lY", "Times-Roman", 14.0, Charset::Big5);
908        assert_eq!(l.lines.len(), 2);
909        assert_eq!(l.lines[0].text, "\u{4e2d}\\nX");
910        let u = make_simple_label_cs("\u{4e2d}\\nX\\lY", "Times-Roman", 14.0, Charset::Utf8);
911        assert_eq!(u.lines.len(), 3);
912    }
913
914    #[test]
915    fn subst_leaves_inapplicable_escapes() {
916        let ctx = LabelSubstContext::for_node("n");
917        let l = make_text_label("\\N \\T \\L", &ctx, "Times-Roman", 14.0, Charset::Utf8);
918        assert_eq!(l.lines[0].text, "n T L");
919    }
920}