Skip to main content

cqlib_core/visualization/circuit/
figure.rs

1// This code is part of Cqlib.
2//
3// (C) Copyright China Telecom Quantum Group 2026
4//
5// This code is licensed under the Apache License, Version 2.0. You may
6// obtain a copy of this license in the LICENSE.txt file in the root directory
7// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8//
9// Any modifications or derivative works of this code must retain this
10// copyright notice, and modified files need to carry a notice indicating
11// that they have been altered from the originals.
12
13//! Figure visualization backend.
14//!
15//! This module renders circuits with a Rust-native SVG-first pipeline and optional PNG
16//! rasterization (via `resvg`).
17//!
18//! # Core Features
19//!
20//! - **SVG-first rendering**: directly generates scalable vector output.
21//! - **Optional PNG export**: rasterizes SVG through `resvg` when bitmap output is needed.
22//! - **Shared IR pipeline**: consumes [`VisualCircuit`] built by the common visualization builder.
23//! - **Style-map driven drawing**: gate colors/fonts/line styles come from `styles/default.json`
24//!   with optional overrides.
25//!
26//! # Typical Workflow
27//!
28//! 1. Build visualization IR from a circuit.
29//! 2. Convert IR into SVG output.
30//! 3. Save SVG directly or rasterize to PNG.
31//!
32//! # Examples
33//!
34//! ```rust
35//! use cqlib_core::circuit::{Circuit, Qubit};
36//! use cqlib_core::visualization::{FigureDrawerOptions, circuit_to_figure};
37//!
38//! let mut circuit = Circuit::new(2);
39//! circuit.h(Qubit::new(0)).unwrap();
40//! circuit.cx(Qubit::new(0), Qubit::new(1)).unwrap();
41//!
42//! let script = circuit_to_figure(&circuit, &FigureDrawerOptions::default()).unwrap();
43//! assert!(script.contains("<svg"));
44//! ```
45
46use crate::circuit::Circuit;
47use crate::visualization::VisualizationError;
48use crate::visualization::circuit::ir::{
49    VisualCircuit, VisualControlFlowKind, VisualOpStyle, VisualOperation,
50    flatten_control_flow_visual, reverse_visual_lanes,
51};
52use crate::visualization::circuit::layout::{VisualBuildOptions, build_visual_circuit};
53use crate::visualization::circuit::params::ParameterFormatOptions;
54use crate::visualization::circuit::style::{GateStyle, StyleBook};
55use crate::visualization::svg::render_svg_to_file;
56use std::collections::HashMap;
57
58#[derive(Debug, Clone, Copy)]
59struct FigurePalette {
60    wire_color: &'static str,
61    connector_color: &'static str,
62    text_color: &'static str,
63    gate_edge_color: &'static str,
64    gate_fill_color: Option<&'static str>,
65    barrier_color: &'static str,
66    wire_linewidth: f64,
67    connector_linewidth: f64,
68    gate_linewidth: f64,
69    gate_fontsize: u8,
70}
71
72/// Minimum logical width reserved for an empty folded row.
73const MIN_ROW_LOGICAL_WIDTH: f64 = 2.0;
74/// Logical Y pitch between adjacent qubit wires.
75const WIRE_PITCH: f64 = 2.0;
76/// Left X bound of the drawing area (keeps qubit labels visible).
77const CANVAS_MIN_X: f64 = -1.4;
78/// Extra padding on the right side of the drawing area.
79const CANVAS_RIGHT_PADDING: f64 = 0.3;
80/// Symmetric Y padding around the drawing area.
81const CANVAS_Y_PADDING: f64 = 1.0;
82/// Base pixel scale per logical unit (further scaled by options).
83const LOGICAL_UNIT_TO_PX: f64 = 80.0;
84/// Inner text padding for gate labels (in pixels).
85const LABEL_INNER_PADDING_PX: f64 = 10.0;
86/// Minimum usable text area per direction (in pixels).
87const LABEL_MIN_INNER_PX: f64 = 4.0;
88/// Relative parameter font size against the gate label font.
89const PARAM_FONT_SCALE: f64 = 0.78;
90/// Relative vertical gap between gate name and parameter line.
91const LABEL_LINE_GAP_SCALE: f64 = 0.22;
92/// SVG tspan font-size used for rendered formula subscripts.
93const SUBSCRIPT_FONT_SCALE: f64 = 0.70;
94/// Minimum fallback scale for labels that still overflow after width reservation.
95const LABEL_FIT_MIN_SCALE: f64 = 0.72;
96/// Extra headroom when packing columns into folded rows.
97const FOLD_TARGET_SLACK: f64 = 1.12;
98/// SVG canvas background color.
99const CANVAS_BACKGROUND_COLOR: &str = "#ffffff";
100/// Default rasterization DPI used as the 1.0 PNG scale baseline.
101const DEFAULT_FIGURE_DPI: u32 = 160;
102
103/// Figure rendering theme.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum FigureDrawStyle {
106    /// Cqlib default style loaded from `styles/default.json`.
107    Cqlib,
108}
109
110/// Options for figure drawing.
111///
112/// Figure rendering consumes [`crate::visualization::VisualCircuit`] IR and supports SVG-first
113/// output with optional PNG rasterization through [`crate::visualization::render_figure_to_file`].
114#[derive(Debug, Clone)]
115pub struct FigureDrawerOptions {
116    /// Whether to append parameter text to gate labels.
117    pub show_params: bool,
118    /// Whether to decompose circuit-gates before drawing.
119    pub decompose_circuit_gates: bool,
120    /// Parameter display format used by visualization IR builder.
121    pub parameter_format: ParameterFormatOptions,
122    /// Figure width scale per logical column.
123    pub width_per_column: f64,
124    /// Figure height scale per qubit.
125    pub height_per_qubit: f64,
126    /// Rasterization DPI when exporting PNG.
127    ///
128    /// The default value (`160`) maps to the native SVG pixel size. Larger values
129    /// scale PNG dimensions proportionally while leaving SVG output unchanged.
130    pub dpi: u32,
131    /// Base gate width (data units), also used as minimum column width.
132    pub gate_width: f64,
133    /// Base gate height (data units).
134    pub gate_height: f64,
135    /// Horizontal spacing between adjacent columns.
136    pub moment_spacing: f64,
137    /// Vertical spacing between folded rows.
138    pub connect_height: f64,
139    /// Maximum columns per row (`-1` disables folding).
140    pub fold: i32,
141    /// Plot style preset.
142    pub style: FigureDrawStyle,
143    /// Optional per-gate style overrides (merged over base style map).
144    pub gate_styles: HashMap<String, GateStyle>,
145    /// Whether to show `|0>` in qubit labels.
146    pub initial_state: bool,
147    /// Whether to reverse display order of qubits.
148    pub reverse_bits: bool,
149}
150
151impl FigureDrawerOptions {
152    /// Converts figure DPI into the PNG rasterization scale.
153    ///
154    /// The default DPI maps to scale `1.0` so existing visual references stay stable.
155    /// Larger DPI values produce proportionally larger PNG dimensions.
156    fn png_scale(&self) -> Result<f64, VisualizationError> {
157        if self.dpi == 0 {
158            return Err(VisualizationError::InvalidInput(
159                "figure dpi must be greater than zero".to_string(),
160            ));
161        }
162        Ok(self.dpi as f64 / DEFAULT_FIGURE_DPI as f64)
163    }
164}
165
166impl Default for FigureDrawerOptions {
167    fn default() -> Self {
168        Self {
169            show_params: true,
170            decompose_circuit_gates: false,
171            parameter_format: ParameterFormatOptions::default(),
172            width_per_column: 1.2,
173            height_per_qubit: 0.9,
174            dpi: DEFAULT_FIGURE_DPI,
175            gate_width: 1.1,
176            gate_height: 1.5,
177            moment_spacing: 0.3,
178            connect_height: 2.0,
179            fold: 18,
180            style: FigureDrawStyle::Cqlib,
181            gate_styles: HashMap::new(),
182            initial_state: false,
183            reverse_bits: false,
184        }
185    }
186}
187
188fn figure_palette(style: FigureDrawStyle) -> FigurePalette {
189    match style {
190        FigureDrawStyle::Cqlib => FigurePalette {
191            wire_color: "black",
192            connector_color: "black",
193            text_color: "black",
194            gate_edge_color: "black",
195            gate_fill_color: Some("white"),
196            barrier_color: "gray",
197            wire_linewidth: 1.1,
198            connector_linewidth: 1.0,
199            gate_linewidth: 1.1,
200            gate_fontsize: 9,
201        },
202    }
203}
204
205/// Generate SVG markup for a circuit.
206///
207/// # Arguments
208///
209/// * `circuit` - Input circuit to render.
210/// * `options` - Figure rendering options.
211///
212/// # Returns
213///
214/// SVG markup as a UTF-8 string.
215///
216/// # Errors
217///
218/// Returns [`VisualizationError`] when IR build fails (for example, unknown qubit references).
219///
220/// # Examples
221///
222/// ```rust
223/// use cqlib_core::circuit::{Circuit, Qubit};
224/// use cqlib_core::visualization::{FigureDrawerOptions, circuit_to_figure};
225///
226/// let mut circuit = Circuit::new(1);
227/// circuit.h(Qubit::new(0)).unwrap();
228///
229/// let script = circuit_to_figure(&circuit, &FigureDrawerOptions::default()).unwrap();
230/// assert!(script.contains("<svg"));
231/// ```
232pub fn circuit_to_figure(
233    circuit: &Circuit,
234    options: &FigureDrawerOptions,
235) -> Result<String, VisualizationError> {
236    let visual_options = VisualBuildOptions {
237        decompose_circuit_gates: options.decompose_circuit_gates,
238        parameter_format: options.parameter_format,
239        ..VisualBuildOptions::default()
240    };
241    let visual = build_visual_circuit(circuit, &visual_options)?;
242    Ok(draw_figure_svg_from_visual(&visual, options))
243}
244
245/// Render a circuit directly to an output file (`.svg` or `.png`).
246///
247/// # Arguments
248///
249/// * `circuit` - Input circuit to render.
250/// * `output_path` - Target file path. `.svg` writes vector output, `.png` writes raster output.
251/// * `options` - Figure rendering options.
252///
253/// # Errors
254///
255/// Returns [`VisualizationError`] when IR build, file writing, or PNG rasterization fails.
256///
257/// # Examples
258///
259/// ```no_run
260/// use cqlib_core::circuit::{Circuit, Qubit};
261/// use cqlib_core::visualization::{FigureDrawerOptions, render_figure_to_file};
262///
263/// let mut circuit = Circuit::new(1);
264/// circuit.h(Qubit::new(0)).unwrap();
265///
266/// render_figure_to_file(&circuit, "circuit.png", &FigureDrawerOptions::default()).unwrap();
267/// ```
268pub fn render_figure_to_file(
269    circuit: &Circuit,
270    output_path: &str,
271    options: &FigureDrawerOptions,
272) -> Result<(), VisualizationError> {
273    let visual_options = VisualBuildOptions {
274        decompose_circuit_gates: options.decompose_circuit_gates,
275        parameter_format: options.parameter_format,
276        ..VisualBuildOptions::default()
277    };
278    let visual = build_visual_circuit(circuit, &visual_options)?;
279    let svg = draw_figure_svg_from_visual(&visual, options);
280    render_svg_to_file(&svg, output_path, options.png_scale()?)
281}
282
283fn draw_figure_svg_from_visual(visual: &VisualCircuit, options: &FigureDrawerOptions) -> String {
284    let mut visual_data = flatten_control_flow_visual(visual);
285    if options.reverse_bits {
286        visual_data = reverse_visual_lanes(visual_data);
287    }
288
289    let num_qubits = visual_data.num_qubits();
290    let num_columns = visual_data.num_columns.max(1);
291    let palette = figure_palette(options.style);
292    let style_book = StyleBook::new("default", &options.gate_styles);
293    let sx = LOGICAL_UNIT_TO_PX * options.width_per_column;
294    let sy = LOGICAL_UNIT_TO_PX * options.height_per_qubit;
295    let global_text_fs = style_book
296        .get("default")
297        .font_size
298        .unwrap_or(palette.gate_fontsize as f64)
299        .clamp(8.0, 48.0);
300
301    let mut cols_ops: Vec<Vec<&_>> = vec![Vec::new(); num_columns];
302    for op in &visual_data.operations {
303        if op.column < num_columns {
304            cols_ops[op.column].push(op);
305        }
306    }
307    // Keep the column-width contract centralized so parameterized gates, module
308    // gates, and control-flow markers use the same text measurement policy.
309    let mut col_widths = vec![options.gate_width; num_columns];
310    for col in 0..num_columns {
311        for op in &cols_ops[col] {
312            let gate_style = style_book.get(op_style_key(op));
313            let gate_text_fs = gate_font_size(gate_style, global_text_fs);
314            col_widths[col] = col_widths[col].max(gate_label_required_width(
315                op,
316                options.show_params,
317                gate_text_fs,
318                sx,
319                options.gate_width,
320            ));
321        }
322    }
323
324    let row_columns = split_columns_by_fold(
325        &col_widths,
326        options.fold,
327        options.moment_spacing,
328        options.gate_width,
329    );
330    let mut row_widths = Vec::with_capacity(row_columns.len());
331    for row in &row_columns {
332        if row.is_empty() {
333            row_widths.push(MIN_ROW_LOGICAL_WIDTH);
334            continue;
335        }
336        let mut width = options.moment_spacing;
337        for (i, col) in row.iter().enumerate() {
338            width += col_widths[*col];
339            if i + 1 < row.len() {
340                width += options.moment_spacing;
341            }
342        }
343        width += options.moment_spacing;
344        row_widths.push(width.max(MIN_ROW_LOGICAL_WIDTH));
345    }
346    let x_max = row_widths
347        .iter()
348        .fold(0.0f64, |acc, w| acc.max(*w))
349        .max(MIN_ROW_LOGICAL_WIDTH);
350    let qubits_height = if num_qubits == 0 {
351        WIRE_PITCH
352    } else {
353        (num_qubits as f64 - 1.0) * WIRE_PITCH
354    };
355    let total_height = if row_columns.is_empty() {
356        WIRE_PITCH
357    } else {
358        (row_columns.len() as f64 - 1.0) * (qubits_height + options.connect_height) + qubits_height
359    };
360
361    let min_x = CANVAS_MIN_X;
362    let max_x = x_max + CANVAS_RIGHT_PADDING;
363    let min_y = -CANVAS_Y_PADDING;
364    let max_y = total_height + CANVAS_Y_PADDING;
365    let canvas_w = ((max_x - min_x) * sx).max(1.0);
366    let canvas_h = ((max_y - min_y) * sy).max(1.0);
367    let px = |x: f64| (x - min_x) * sx;
368    let py = |y: f64| (y - min_y) * sy;
369
370    let mut elements = Vec::new();
371    elements.push(format!(
372        "<rect x=\"0\" y=\"0\" width=\"{:.3}\" height=\"{:.3}\" fill=\"{}\"/>",
373        canvas_w, canvas_h, CANVAS_BACKGROUND_COLOR
374    ));
375
376    let wire_color = style_book
377        .get("default")
378        .line_color
379        .as_deref()
380        .unwrap_or(palette.wire_color);
381    let default_text_color = style_book
382        .get("default")
383        .text_color
384        .as_deref()
385        .unwrap_or(palette.text_color);
386    for (row_idx, row_cols) in row_columns.iter().enumerate() {
387        // Keep all folded rows at a consistent visual width.
388        let row_x_max = x_max;
389        let y_base = row_idx as f64 * (qubits_height + options.connect_height);
390
391        for (lane, qubit) in visual_data.qubits.iter().enumerate() {
392            let y = lane_to_y(lane, y_base);
393            let q_label = if options.initial_state {
394                format!("q{} |0>", qubit.id())
395            } else {
396                format!("q{}", qubit.id())
397            };
398            elements.push(svg_line(
399                px(0.0),
400                py(y),
401                px(row_x_max),
402                py(y),
403                wire_color,
404                palette.wire_linewidth,
405                None,
406            ));
407            elements.push(svg_text(
408                px(-0.08),
409                py(y),
410                &q_label,
411                global_text_fs,
412                default_text_color,
413                "end",
414            ));
415        }
416        if row_idx > 0 && num_qubits > 0 {
417            elements.push(svg_line(
418                px(0.0),
419                py(y_base),
420                px(0.0),
421                py(y_base + qubits_height),
422                wire_color,
423                palette.wire_linewidth * 1.3,
424                None,
425            ));
426        }
427        if row_idx + 1 < row_columns.len() && num_qubits > 0 {
428            elements.push(svg_line(
429                px(row_x_max),
430                py(y_base),
431                px(row_x_max),
432                py(y_base + qubits_height),
433                wire_color,
434                palette.wire_linewidth * 1.3,
435                None,
436            ));
437        }
438
439        let mut x = options.moment_spacing;
440        for (i, col) in row_cols.iter().enumerate() {
441            let col_w = col_widths[*col];
442            x += col_w / 2.0;
443            let x_center = x;
444
445            for op in &cols_ops[*col] {
446                // Only label-heavy boxes consume the measured column width; simple gates keep the
447                // fixed footprint used by existing visual baselines.
448                let op_w = if uses_measured_label_width(op, options.show_params) {
449                    col_w
450                } else {
451                    options.gate_width
452                };
453                let label = compose_label(&op.label, &op.params, options.show_params);
454                let gate_style = style_book.get(op_style_key(op));
455                let gate_text_fs = gate_font_size(gate_style, global_text_fs);
456                let min_lane = op.covered_lanes.iter().copied().min();
457                let max_lane = op.covered_lanes.iter().copied().max();
458                let connector_color = gate_style
459                    .line_color
460                    .as_deref()
461                    .unwrap_or(palette.connector_color);
462                let connector_lw = gate_style.line_width.unwrap_or(palette.connector_linewidth);
463
464                match &op.style {
465                    VisualOpStyle::Gate => {
466                        if op.label == "FSIM" && op.lanes.len() >= 2 {
467                            if let (Some(min_l), Some(max_l)) = (
468                                op.lanes.iter().copied().min(),
469                                op.lanes.iter().copied().max(),
470                            ) && max_l > min_l
471                            {
472                                let y0 = lane_to_y(min_l, y_base);
473                                let y1 = lane_to_y(max_l, y_base);
474                                elements.push(svg_line(
475                                    px(x_center),
476                                    py(y0.min(y1)),
477                                    px(x_center),
478                                    py(y0.max(y1)),
479                                    connector_color,
480                                    connector_lw,
481                                    None,
482                                ));
483                            }
484                            let r = (options.gate_width * 0.35 * sx.min(sy)).clamp(14.0, 28.0);
485                            let circle_face = normalized_fill_color(gate_style, &palette)
486                                .unwrap_or_else(|| "white".to_string());
487                            let circle_edge = normalized_edge_color(gate_style, &palette)
488                                .unwrap_or_else(|| connector_color.to_string());
489                            let text_color = gate_style
490                                .text_color
491                                .as_deref()
492                                .unwrap_or(palette.text_color);
493                            // Keep FSIM text inside the circular marker.
494                            let fsim_font = (r * 0.62).clamp(7.0, (gate_text_fs * 0.9).max(7.0));
495                            for lane in &op.lanes {
496                                let y = lane_to_y(*lane, y_base);
497                                elements.push(svg_circle(
498                                    px(x_center),
499                                    py(y),
500                                    r,
501                                    Some(&circle_face),
502                                    Some(&circle_edge),
503                                    connector_lw,
504                                ));
505                                elements.push(svg_text(
506                                    px(x_center),
507                                    py(y),
508                                    "FSIM",
509                                    fsim_font,
510                                    text_color,
511                                    "middle",
512                                ));
513                            }
514                            continue;
515                        }
516
517                        if op.lanes.len() > 1 {
518                            let gate_box_w = op_w;
519                            let show_markers = show_span_lane_markers(op);
520                            let marker_gutter = if show_markers {
521                                (gate_box_w * 0.28).clamp(0.24, 0.42)
522                            } else {
523                                0.0
524                            };
525                            let start_lane = op.lanes.iter().copied().min().unwrap_or(0);
526                            let end_lane = op.lanes.iter().copied().max().unwrap_or(start_lane);
527                            let y0 = lane_to_y(start_lane, y_base);
528                            let y1 = lane_to_y(end_lane, y_base);
529                            let y_min = y0.min(y1) - options.gate_height / 2.0;
530                            let box_h = (y0.max(y1) - y0.min(y1) + options.gate_height)
531                                .max(options.gate_height);
532                            elements.extend(draw_span_box_svg(
533                                // Keep span-gate center strictly aligned to the moment center.
534                                x_center,
535                                y_min,
536                                box_h,
537                                &label,
538                                &palette,
539                                gate_style,
540                                gate_text_fs,
541                                gate_box_w,
542                                sx,
543                                sy,
544                                &px,
545                                &py,
546                            ));
547                            if show_markers {
548                                // Keep lane markers inside the box with a small left inset.
549                                let marker_x = x_center - gate_box_w / 2.0 + marker_gutter * 0.2;
550                                let marker_font_size = gate_text_fs;
551                                for (idx, lane) in op.lanes.iter().enumerate() {
552                                    let y = lane_to_y(*lane, y_base);
553                                    elements.push(svg_text(
554                                        px(marker_x),
555                                        py(y),
556                                        &idx.to_string(),
557                                        marker_font_size,
558                                        gate_style
559                                            .text_color
560                                            .as_deref()
561                                            .unwrap_or(palette.text_color),
562                                        "start",
563                                    ));
564                                }
565                            }
566                        } else {
567                            let anchor = op.lanes.iter().copied().min().unwrap_or(0);
568                            let y = lane_to_y(anchor, y_base);
569                            elements.extend(draw_box_svg(
570                                x_center,
571                                y,
572                                &label,
573                                &palette,
574                                gate_style,
575                                gate_text_fs,
576                                op_w,
577                                options.gate_height,
578                                sx,
579                                sy,
580                                &px,
581                                &py,
582                            ));
583                        }
584                    }
585                    VisualOpStyle::Controlled { num_controls } => {
586                        if let (Some(min_l), Some(max_l)) = (
587                            op.lanes.iter().copied().min(),
588                            op.lanes.iter().copied().max(),
589                        ) && max_l > min_l
590                        {
591                            let y0 = lane_to_y(min_l, y_base);
592                            let y1 = lane_to_y(max_l, y_base);
593                            elements.push(svg_line(
594                                px(x_center),
595                                py(y0.min(y1)),
596                                px(x_center),
597                                py(y0.max(y1)),
598                                connector_color,
599                                connector_lw,
600                                None,
601                            ));
602                        }
603                        for lane in op.lanes.iter().take(*num_controls) {
604                            let y = lane_to_y(*lane, y_base);
605                            elements.push(svg_circle(
606                                px(x_center),
607                                py(y),
608                                (0.07 * sx.min(sy)).max(4.0),
609                                Some(connector_color),
610                                Some(connector_color),
611                                connector_lw,
612                            ));
613                        }
614                        for lane in op.lanes.iter().skip(*num_controls) {
615                            let y = lane_to_y(*lane, y_base);
616                            elements.extend(draw_box_svg(
617                                x_center,
618                                y,
619                                &label,
620                                &palette,
621                                gate_style,
622                                gate_text_fs,
623                                op_w,
624                                options.gate_height,
625                                sx,
626                                sy,
627                                &px,
628                                &py,
629                            ));
630                        }
631                    }
632                    VisualOpStyle::Cz => {
633                        if let (Some(min_l), Some(max_l)) = (
634                            op.lanes.iter().copied().min(),
635                            op.lanes.iter().copied().max(),
636                        ) && max_l > min_l
637                        {
638                            let y0 = lane_to_y(min_l, y_base);
639                            let y1 = lane_to_y(max_l, y_base);
640                            elements.push(svg_line(
641                                px(x_center),
642                                py(y0.min(y1)),
643                                px(x_center),
644                                py(y0.max(y1)),
645                                connector_color,
646                                connector_lw,
647                                None,
648                            ));
649                        }
650                        for lane in &op.lanes {
651                            let y = lane_to_y(*lane, y_base);
652                            elements.push(svg_circle(
653                                px(x_center),
654                                py(y),
655                                (0.07 * sx.min(sy)).max(4.0),
656                                Some(connector_color),
657                                Some(connector_color),
658                                connector_lw,
659                            ));
660                        }
661                    }
662                    VisualOpStyle::Swap => {
663                        let swap_lw = connector_lw * 1.8;
664                        if let (Some(min_l), Some(max_l)) = (
665                            op.lanes.iter().copied().min(),
666                            op.lanes.iter().copied().max(),
667                        ) && max_l > min_l
668                        {
669                            let y0 = lane_to_y(min_l, y_base);
670                            let y1 = lane_to_y(max_l, y_base);
671                            elements.push(svg_line(
672                                px(x_center),
673                                py(y0.min(y1)),
674                                px(x_center),
675                                py(y0.max(y1)),
676                                connector_color,
677                                swap_lw,
678                                None,
679                            ));
680                        }
681                        for lane in &op.lanes {
682                            let y = lane_to_y(*lane, y_base);
683                            elements.push(svg_line(
684                                px(x_center - 0.15),
685                                py(y - 0.15),
686                                px(x_center + 0.15),
687                                py(y + 0.15),
688                                connector_color,
689                                swap_lw,
690                                None,
691                            ));
692                            elements.push(svg_line(
693                                px(x_center - 0.15),
694                                py(y + 0.15),
695                                px(x_center + 0.15),
696                                py(y - 0.15),
697                                connector_color,
698                                swap_lw,
699                                None,
700                            ));
701                        }
702                    }
703                    VisualOpStyle::Barrier => {
704                        let barrier_lw =
705                            gate_style.line_width.unwrap_or(palette.gate_linewidth) * 1.8;
706                        let (start_lane, end_lane) =
707                            if let (Some(min_l), Some(max_l)) = (min_lane, max_lane) {
708                                (min_l, max_l)
709                            } else if num_qubits > 0 {
710                                (0, num_qubits - 1)
711                            } else {
712                                (0, 0)
713                            };
714                        let y0 = lane_to_y(start_lane, y_base);
715                        let y1 = lane_to_y(end_lane, y_base);
716                        elements.push(svg_line(
717                            px(x_center),
718                            py(y0.min(y1) - options.gate_height / 2.0),
719                            px(x_center),
720                            py(y0.max(y1) + options.gate_height / 2.0),
721                            gate_style
722                                .line_color
723                                .as_deref()
724                                .unwrap_or(palette.barrier_color),
725                            barrier_lw,
726                            Some("6,4"),
727                        ));
728                    }
729                    VisualOpStyle::Measure => {
730                        if op.lanes.is_empty() {
731                            elements.extend(draw_measure_svg(
732                                x_center,
733                                lane_to_y(0, y_base),
734                                &palette,
735                                gate_style,
736                                gate_text_fs,
737                                op_w,
738                                options.gate_height,
739                                sx,
740                                sy,
741                                &px,
742                                &py,
743                            ));
744                        } else {
745                            for lane in &op.lanes {
746                                elements.extend(draw_measure_svg(
747                                    x_center,
748                                    lane_to_y(*lane, y_base),
749                                    &palette,
750                                    gate_style,
751                                    gate_text_fs,
752                                    op_w,
753                                    options.gate_height,
754                                    sx,
755                                    sy,
756                                    &px,
757                                    &py,
758                                ));
759                            }
760                        }
761                    }
762                    VisualOpStyle::Reset | VisualOpStyle::Delay => {
763                        if op.lanes.is_empty() {
764                            elements.extend(draw_box_svg(
765                                x_center,
766                                lane_to_y(0, y_base),
767                                &label,
768                                &palette,
769                                gate_style,
770                                gate_text_fs,
771                                op_w,
772                                options.gate_height,
773                                sx,
774                                sy,
775                                &px,
776                                &py,
777                            ));
778                        } else {
779                            for lane in &op.lanes {
780                                elements.extend(draw_box_svg(
781                                    x_center,
782                                    lane_to_y(*lane, y_base),
783                                    &label,
784                                    &palette,
785                                    gate_style,
786                                    gate_text_fs,
787                                    op_w,
788                                    options.gate_height,
789                                    sx,
790                                    sy,
791                                    &px,
792                                    &py,
793                                ));
794                            }
795                        }
796                    }
797                    VisualOpStyle::ControlFlow { kind } => {
798                        let start_lane = min_lane
799                            .or_else(|| op.lanes.iter().copied().min())
800                            .unwrap_or(0);
801                        let end_lane = max_lane
802                            .or_else(|| op.lanes.iter().copied().max())
803                            .unwrap_or(start_lane);
804                        let y0 = lane_to_y(start_lane, y_base);
805                        let y1 = lane_to_y(end_lane, y_base);
806                        let y_min = y0.min(y1) - options.gate_height / 2.0;
807                        let box_h = (y0.max(y1) - y0.min(y1) + options.gate_height)
808                            .max(options.gate_height);
809                        elements.extend(draw_flow_box_svg(
810                            x_center,
811                            y_min,
812                            box_h,
813                            &label,
814                            &palette,
815                            gate_style,
816                            gate_text_fs,
817                            op_w,
818                            sx,
819                            sy,
820                            &px,
821                            &py,
822                        ));
823                        if matches!(
824                            kind,
825                            VisualControlFlowKind::IfElseBlock {
826                                has_false_branch: true,
827                                ..
828                            }
829                        ) {
830                            elements.push(svg_text(
831                                px(x_center + op_w / 2.0 + 0.08),
832                                py(y_min + box_h - 0.12),
833                                "else",
834                                gate_text_fs,
835                                gate_style
836                                    .text_color
837                                    .as_deref()
838                                    .unwrap_or(palette.text_color),
839                                "start",
840                            ));
841                        }
842                    }
843                }
844            }
845
846            x += col_w / 2.0;
847            if i + 1 < row_cols.len() {
848                x += options.moment_spacing;
849            }
850        }
851    }
852
853    let mut out = String::new();
854    out.push_str(&format!(
855        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{:.0}\" height=\"{:.0}\" viewBox=\"0 0 {:.3} {:.3}\">",
856        canvas_w, canvas_h, canvas_w, canvas_h
857    ));
858    for e in elements {
859        out.push_str(&e);
860    }
861    out.push_str("</svg>");
862    out
863}
864
865/// Generate SVG markup from pre-built visualization IR.
866///
867/// Use this API when you want to cache or transform [`VisualCircuit`] once and render it
868/// multiple times with different figure options.
869///
870/// # Arguments
871///
872/// * `visual` - Pre-built visualization IR produced by [`build_visual_circuit`].
873/// * `options` - Figure rendering options.
874/// * `_output_path` - Reserved for API compatibility; currently ignored.
875///
876/// # Returns
877///
878/// SVG markup as a UTF-8 string.
879///
880/// # Examples
881///
882/// ```rust
883/// use cqlib_core::circuit::{Circuit, Qubit};
884/// use cqlib_core::visualization::{
885///     FigureDrawerOptions, VisualBuildOptions, build_visual_circuit, draw_figure_from_visual,
886/// };
887///
888/// let mut circuit = Circuit::new(1);
889/// circuit.h(Qubit::new(0)).unwrap();
890/// let visual = build_visual_circuit(&circuit, &VisualBuildOptions::default()).unwrap();
891/// let svg = draw_figure_from_visual(&visual, &FigureDrawerOptions::default(), None);
892/// assert!(svg.contains("<svg"));
893/// ```
894pub fn draw_figure_from_visual(
895    visual: &VisualCircuit,
896    options: &FigureDrawerOptions,
897    _output_path: Option<&str>,
898) -> String {
899    draw_figure_svg_from_visual(visual, options)
900}
901
902fn svg_line(
903    x1: f64,
904    y1: f64,
905    x2: f64,
906    y2: f64,
907    stroke: &str,
908    stroke_width: f64,
909    dash: Option<&str>,
910) -> String {
911    let dash_attr = dash
912        .map(|d| format!(" stroke-dasharray=\"{}\"", d))
913        .unwrap_or_default();
914    format!(
915        "<line x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" stroke=\"{}\" stroke-width=\"{:.3}\"{} />",
916        x1, y1, x2, y2, stroke, stroke_width, dash_attr
917    )
918}
919
920#[allow(clippy::too_many_arguments)]
921fn svg_rect(
922    x: f64,
923    y: f64,
924    w: f64,
925    h: f64,
926    fill: &str,
927    stroke: &str,
928    lw: f64,
929    dash: Option<&str>,
930) -> String {
931    let dash_attr = dash
932        .map(|d| format!(" stroke-dasharray=\"{}\"", d))
933        .unwrap_or_default();
934    format!(
935        "<rect x=\"{:.3}\" y=\"{:.3}\" width=\"{:.3}\" height=\"{:.3}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"{:.3}\"{} />",
936        x, y, w, h, fill, stroke, lw, dash_attr
937    )
938}
939
940fn svg_circle(
941    cx: f64,
942    cy: f64,
943    r: f64,
944    fill: Option<&str>,
945    stroke: Option<&str>,
946    lw: f64,
947) -> String {
948    format!(
949        "<circle cx=\"{:.3}\" cy=\"{:.3}\" r=\"{:.3}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"{:.3}\" />",
950        cx,
951        cy,
952        r,
953        fill.unwrap_or("none"),
954        stroke.unwrap_or("none"),
955        lw
956    )
957}
958
959fn svg_text(x: f64, y: f64, text: &str, font_size: f64, color: &str, anchor: &str) -> String {
960    svg_text_with_content(x, y, font_size, color, anchor, &escape_xml(text))
961}
962
963/// Render parameter text with simple SVG subscripts while preserving XML escaping.
964fn svg_formula_text(
965    x: f64,
966    y: f64,
967    text: &str,
968    font_size: f64,
969    color: &str,
970    anchor: &str,
971) -> String {
972    svg_text_with_content(x, y, font_size, color, anchor, &format_svg_subscripts(text))
973}
974
975fn svg_text_with_content(
976    x: f64,
977    y: f64,
978    font_size: f64,
979    color: &str,
980    anchor: &str,
981    content: &str,
982) -> String {
983    format!(
984        "<text x=\"{:.3}\" y=\"{:.3}\" fill=\"{}\" font-size=\"{:.3}\" font-family=\"DejaVu Sans, Arial, sans-serif\" text-anchor=\"{}\" dominant-baseline=\"middle\">{}</text>",
985        x, y, color, font_size, anchor, content
986    )
987}
988
989#[allow(clippy::too_many_arguments)]
990fn draw_gate_label_svg(
991    x_px: f64,
992    y_px: f64,
993    label: &str,
994    text_color: &str,
995    box_w_px: f64,
996    box_h_px: f64,
997    base_name_fs: f64,
998    allow_shrink: bool,
999) -> Vec<String> {
1000    let mut parts = label.splitn(2, '\n');
1001    let name = parts.next().unwrap_or_default();
1002    let param = parts.next().filter(|s| !s.is_empty());
1003    let avail_w = (box_w_px - 2.0 * LABEL_INNER_PADDING_PX).max(LABEL_MIN_INNER_PX);
1004    let avail_h = (box_h_px - LABEL_INNER_PADDING_PX).max(LABEL_MIN_INNER_PX);
1005    let nfs = base_name_fs.max(1.0);
1006    let mut pfs = (nfs * PARAM_FONT_SCALE).max(1.0);
1007
1008    // Width reservation should avoid most shrinkage. This branch only handles
1009    // fixed-size boxes where a two-line label would otherwise exceed the box height.
1010    if allow_shrink && param.is_some() {
1011        let gap = nfs * LABEL_LINE_GAP_SCALE;
1012        let need_h = nfs + gap + pfs;
1013        if need_h > avail_h {
1014            let height_room = (avail_h - nfs - gap).max(1.0);
1015            pfs *= height_room / pfs.max(1.0);
1016        }
1017    }
1018
1019    let name_fs = fitted_font_size(svg_text_width_px(name, nfs), nfs, avail_w, allow_shrink);
1020    let param_fs = param
1021        .map(|p| fitted_font_size(svg_formula_width_px(p, pfs), pfs, avail_w, allow_shrink))
1022        .unwrap_or(pfs);
1023
1024    let mut out = Vec::new();
1025    if let Some(p) = param {
1026        let gap = name_fs * LABEL_LINE_GAP_SCALE;
1027        let total_h = name_fs + gap + param_fs;
1028        let top = y_px - total_h / 2.0;
1029        let name_y = top + name_fs / 2.0;
1030        let param_y = name_y + (name_fs / 2.0 + gap + param_fs / 2.0);
1031        out.push(svg_text(x_px, name_y, name, name_fs, text_color, "middle"));
1032        out.push(svg_formula_text(
1033            x_px, param_y, p, param_fs, text_color, "middle",
1034        ));
1035    } else {
1036        out.push(svg_text(x_px, y_px, name, name_fs, text_color, "middle"));
1037    }
1038    out
1039}
1040
1041#[allow(clippy::too_many_arguments)]
1042fn draw_box_svg(
1043    x: f64,
1044    y: f64,
1045    label: &str,
1046    palette: &FigurePalette,
1047    style: &GateStyle,
1048    base_font_size: f64,
1049    width: f64,
1050    height: f64,
1051    sx: f64,
1052    sy: f64,
1053    px: &impl Fn(f64) -> f64,
1054    py: &impl Fn(f64) -> f64,
1055) -> Vec<String> {
1056    draw_labeled_rect_svg(
1057        x,
1058        y,
1059        width,
1060        height,
1061        label,
1062        palette,
1063        style,
1064        base_font_size,
1065        sx,
1066        sy,
1067        px,
1068        py,
1069        None,
1070        None,
1071        true,
1072    )
1073}
1074
1075#[allow(clippy::too_many_arguments)]
1076fn draw_span_box_svg(
1077    x: f64,
1078    y_min: f64,
1079    height: f64,
1080    label: &str,
1081    palette: &FigurePalette,
1082    style: &GateStyle,
1083    base_font_size: f64,
1084    width: f64,
1085    sx: f64,
1086    sy: f64,
1087    px: &impl Fn(f64) -> f64,
1088    py: &impl Fn(f64) -> f64,
1089) -> Vec<String> {
1090    draw_labeled_rect_svg(
1091        x,
1092        y_min + height / 2.0,
1093        width,
1094        height,
1095        label,
1096        palette,
1097        style,
1098        base_font_size,
1099        sx,
1100        sy,
1101        px,
1102        py,
1103        None,
1104        None,
1105        true,
1106    )
1107}
1108
1109#[allow(clippy::too_many_arguments)]
1110fn draw_flow_box_svg(
1111    x: f64,
1112    y_min: f64,
1113    height: f64,
1114    label: &str,
1115    palette: &FigurePalette,
1116    style: &GateStyle,
1117    base_font_size: f64,
1118    width: f64,
1119    sx: f64,
1120    sy: f64,
1121    px: &impl Fn(f64) -> f64,
1122    py: &impl Fn(f64) -> f64,
1123) -> Vec<String> {
1124    draw_labeled_rect_svg(
1125        x,
1126        y_min + height / 2.0,
1127        width,
1128        height,
1129        label,
1130        palette,
1131        style,
1132        base_font_size,
1133        sx,
1134        sy,
1135        px,
1136        py,
1137        Some("6,4"),
1138        Some(palette.gate_edge_color),
1139        true,
1140    )
1141}
1142
1143#[allow(clippy::too_many_arguments)]
1144fn draw_labeled_rect_svg(
1145    x: f64,
1146    y: f64,
1147    width: f64,
1148    height: f64,
1149    label: &str,
1150    palette: &FigurePalette,
1151    style: &GateStyle,
1152    base_font_size: f64,
1153    sx: f64,
1154    sy: f64,
1155    px: &impl Fn(f64) -> f64,
1156    py: &impl Fn(f64) -> f64,
1157    dash: Option<&str>,
1158    edge_fallback: Option<&str>,
1159    allow_shrink: bool,
1160) -> Vec<String> {
1161    let fill = normalized_fill_color(style, palette).unwrap_or_else(|| "none".to_string());
1162    // Border color defaults to the fill color so the gate box outline matches its background.
1163    let edge = normalized_edge_color(style, palette)
1164        .or_else(|| edge_fallback.map(str::to_string))
1165        .unwrap_or_else(|| fill.clone());
1166    let lw = style_line_width(style, palette);
1167    let text_color = str_color(style.text_color.as_deref(), palette.text_color);
1168    let rx = px(x - width / 2.0);
1169    let ry = py(y - height / 2.0);
1170    let rw = width * sx;
1171    let rh = height * sy;
1172    let mut out = vec![svg_rect(rx, ry, rw, rh, &fill, &edge, lw, dash)];
1173    out.extend(draw_gate_label_svg(
1174        px(x),
1175        py(y),
1176        label,
1177        &text_color,
1178        rw,
1179        rh,
1180        base_font_size,
1181        allow_shrink,
1182    ));
1183    out
1184}
1185
1186#[allow(clippy::too_many_arguments)]
1187fn draw_measure_svg(
1188    x: f64,
1189    y: f64,
1190    palette: &FigurePalette,
1191    style: &GateStyle,
1192    base_font_size: f64,
1193    width: f64,
1194    height: f64,
1195    sx: f64,
1196    sy: f64,
1197    px: &impl Fn(f64) -> f64,
1198    py: &impl Fn(f64) -> f64,
1199) -> Vec<String> {
1200    let mut out = draw_box_svg(
1201        x,
1202        y,
1203        "",
1204        palette,
1205        style,
1206        base_font_size,
1207        width,
1208        height,
1209        sx,
1210        sy,
1211        px,
1212        py,
1213    );
1214    let lw = style_line_width(style, palette);
1215    let line_color = str_color(style.line_color.as_deref(), palette.text_color);
1216
1217    // Gauge arc: larger semicircle in the lower half of the box
1218    let cx = px(x);
1219    let cy = py(y + 0.18 * height);
1220    let rx = 0.35 * width * sx;
1221    let ry = 0.35 * height * sy;
1222    let arc_lw = (lw * 3.0).max(3.0);
1223    let arc_sx = cx - rx;
1224    let arc_ex = cx + rx;
1225    out.push(format!(
1226        "<path d=\"M {:.3} {:.3} A {:.3} {:.3} 0 0 1 {:.3} {:.3}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.3}\" stroke-linecap=\"round\" />",
1227        arc_sx, cy, rx, ry, arc_ex, cy, line_color, arc_lw
1228    ));
1229
1230    // Small circle at the bottom center (pivot point)
1231    let dot_r = (lw * 3.0).max(3.0);
1232    out.push(svg_circle(cx, cy, dot_r, Some(&line_color), None, 0.0));
1233
1234    // Arrow from pivot toward upper-right (45° direction)
1235    let arrow_len_x = rx * 0.9;
1236    let arrow_len_y = ry * 0.9;
1237    let ax1 = cx + arrow_len_x;
1238    let ay1 = cy - arrow_len_y;
1239    out.push(svg_line(cx, cy, ax1, ay1, &line_color, lw, None));
1240
1241    // Arrowhead
1242    let head = (lw * 3.5).max(6.0);
1243    let angle: f64 = std::f64::consts::FRAC_PI_4; // 45°
1244    let perp = angle + std::f64::consts::FRAC_PI_2;
1245    let p1x = ax1 - head * angle.cos() + (head * 0.4) * perp.cos();
1246    let p1y = ay1 + head * angle.sin() - (head * 0.4) * perp.sin();
1247    let p2x = ax1 - head * angle.cos() - (head * 0.4) * perp.cos();
1248    let p2y = ay1 + head * angle.sin() + (head * 0.4) * perp.sin();
1249    out.push(format!(
1250        "<polygon points=\"{:.3},{:.3} {:.3},{:.3} {:.3},{:.3}\" fill=\"{}\"/>",
1251        ax1, ay1, p1x, p1y, p2x, p2y, line_color
1252    ));
1253
1254    // "0" label at upper-left, "1" label at upper-right
1255    let label_fs = (base_font_size * 0.85).max(6.0);
1256    let label_y = py(y - 0.30 * height);
1257    let label_x0 = cx - rx * 0.75;
1258    let label_x1 = cx + rx * 0.75;
1259    out.push(svg_text(
1260        label_x0,
1261        label_y,
1262        "0",
1263        label_fs,
1264        &line_color,
1265        "middle",
1266    ));
1267    out.push(svg_text(
1268        label_x1,
1269        label_y,
1270        "1",
1271        label_fs,
1272        &line_color,
1273        "middle",
1274    ));
1275
1276    out
1277}
1278
1279fn escape_xml(value: &str) -> String {
1280    value
1281        .replace('&', "&amp;")
1282        .replace('<', "&lt;")
1283        .replace('>', "&gt;")
1284        .replace('\"', "&quot;")
1285}
1286
1287/// Convert `name_0`-style suffixes into SVG subscript tspans.
1288///
1289/// The formatter only treats ASCII alphanumeric runs after `_` as subscripts,
1290/// which matches generated parameter names such as `x_11` without parsing math.
1291fn format_svg_subscripts(value: &str) -> String {
1292    let mut out = String::with_capacity(value.len());
1293    let bytes = value.as_bytes();
1294    let mut i = 0usize;
1295
1296    while i < bytes.len() {
1297        if bytes[i] == b'_' && bytes.get(i + 1).is_some_and(is_subscript_suffix_byte) {
1298            let start = i + 1;
1299            let mut end = start;
1300            while bytes.get(end).is_some_and(is_subscript_suffix_byte) {
1301                end += 1;
1302            }
1303            out.push_str("<tspan baseline-shift=\"sub\" font-size=\"70%\">");
1304            out.push_str(&escape_xml(&value[start..end]));
1305            out.push_str("</tspan>");
1306            i = end;
1307            continue;
1308        }
1309
1310        let ch = value[i..].chars().next().unwrap_or_default();
1311        let mut buf = [0; 4];
1312        out.push_str(&escape_xml(ch.encode_utf8(&mut buf)));
1313        i += ch.len_utf8();
1314    }
1315
1316    out
1317}
1318
1319/// Fit one text line into the available box width without collapsing readability.
1320fn fitted_font_size(text_width_px: f64, font_size: f64, avail_w: f64, allow_shrink: bool) -> f64 {
1321    if !allow_shrink || text_width_px <= avail_w || text_width_px <= 0.0 {
1322        return font_size;
1323    }
1324    let scale = (avail_w / text_width_px).clamp(LABEL_FIT_MIN_SCALE, 1.0);
1325    font_size * scale
1326}
1327
1328/// Estimate rendered parameter width using the same subscript policy as SVG output.
1329fn svg_formula_width_px(value: &str, font_size: f64) -> f64 {
1330    let bytes = value.as_bytes();
1331    let mut i = 0usize;
1332    let mut width = 0.0_f64;
1333
1334    while i < bytes.len() {
1335        if bytes[i] == b'_' && bytes.get(i + 1).is_some_and(is_subscript_suffix_byte) {
1336            i += 1;
1337            while bytes.get(i).is_some_and(is_subscript_suffix_byte) {
1338                let ch = value[i..].chars().next().unwrap_or_default();
1339                width += svg_char_width_em(ch) * font_size * SUBSCRIPT_FONT_SCALE;
1340                i += ch.len_utf8();
1341            }
1342            continue;
1343        }
1344
1345        let ch = value[i..].chars().next().unwrap_or_default();
1346        width += svg_char_width_em(ch) * font_size;
1347        i += ch.len_utf8();
1348    }
1349
1350    width
1351}
1352
1353/// Estimate plain SVG text width for layout reservation.
1354fn svg_text_width_px(value: &str, font_size: f64) -> f64 {
1355    value
1356        .chars()
1357        .map(|ch| svg_char_width_em(ch) * font_size)
1358        .sum()
1359}
1360
1361/// Conservative DejaVu Sans width approximation used before browser rendering.
1362fn svg_char_width_em(ch: char) -> f64 {
1363    match ch {
1364        ' ' => 0.33,
1365        'i' | 'j' | 'l' | 'I' | '|' | '!' | '.' | ',' | ':' | ';' | '\'' => 0.28,
1366        '(' | ')' | '[' | ']' | '{' | '}' => 0.35,
1367        '*' | '+' | '-' | '/' | '=' => 0.55,
1368        '0'..='9' => 0.58,
1369        'A'..='Z' => match ch {
1370            'M' | 'W' => 0.90,
1371            _ => 0.68,
1372        },
1373        'a'..='z' => match ch {
1374            'm' | 'w' => 0.82,
1375            'f' | 'r' | 't' => 0.42,
1376            _ => 0.58,
1377        },
1378        '_' => 0.52,
1379        '<' | '>' => 0.62,
1380        'π' | 'θ' | 'φ' | 'λ' | 'γ' | 'β' => 0.62,
1381        _ if ch.is_ascii() => 0.62,
1382        _ => 0.82,
1383    }
1384}
1385
1386/// Return the widest rendered line for a composed gate label.
1387fn label_text_width_px(label: &str, base_font_size: f64) -> f64 {
1388    let mut parts = label.splitn(2, '\n');
1389    let name = parts.next().unwrap_or_default();
1390    let param = parts.next().filter(|s| !s.is_empty());
1391    let name_w = svg_text_width_px(name, base_font_size);
1392    let param_w = param
1393        .map(|p| svg_formula_width_px(p, base_font_size * PARAM_FONT_SCALE))
1394        .unwrap_or(0.0);
1395    name_w.max(param_w)
1396}
1397
1398/// Return whether a byte can be part of the simple subscript suffix syntax.
1399fn is_subscript_suffix_byte(byte: &u8) -> bool {
1400    byte.is_ascii_alphanumeric()
1401}
1402
1403/// Compose a multi-line gate label with optional formatted parameters.
1404fn compose_label(label: &str, params: &[String], show_params: bool) -> String {
1405    if show_params && !params.is_empty() {
1406        format!("{label}\n{}", params.join(","))
1407    } else {
1408        label.to_string()
1409    }
1410}
1411
1412fn show_span_lane_markers(op: &VisualOperation) -> bool {
1413    matches!(op.label.as_str(), "RXX" | "RYY" | "RZX" | "RZZ" | "UNITARY")
1414}
1415
1416fn is_module_span_gate(op: &VisualOperation) -> bool {
1417    matches!(op.style, VisualOpStyle::Gate) && op.span_box
1418}
1419
1420fn is_control_flow_box(op: &VisualOperation) -> bool {
1421    matches!(op.style, VisualOpStyle::ControlFlow { .. })
1422}
1423
1424fn is_parameterized_box_gate(op: &VisualOperation, show_params: bool) -> bool {
1425    show_params
1426        && !op.params.is_empty()
1427        && op.label != "FSIM"
1428        && matches!(
1429            op.style,
1430            VisualOpStyle::Gate | VisualOpStyle::Controlled { .. }
1431        )
1432}
1433
1434fn uses_measured_label_width(op: &VisualOperation, show_params: bool) -> bool {
1435    is_parameterized_box_gate(op, show_params) || is_module_span_gate(op) || is_control_flow_box(op)
1436}
1437
1438fn gate_font_size(style: &GateStyle, fallback_font_size: f64) -> f64 {
1439    style
1440        .font_size
1441        .unwrap_or(fallback_font_size)
1442        .clamp(1.0, 96.0)
1443}
1444
1445/// Compute the logical gate width needed to keep measured labels readable.
1446fn gate_label_required_width(
1447    op: &VisualOperation,
1448    show_params: bool,
1449    base_font_size: f64,
1450    sx: f64,
1451    gate_width: f64,
1452) -> f64 {
1453    if !uses_measured_label_width(op, show_params) {
1454        return gate_width;
1455    }
1456
1457    let text_width_px = if is_control_flow_box(op) {
1458        svg_text_width_px(&op.label, base_font_size)
1459    } else {
1460        let label = compose_label(&op.label, &op.params, show_params);
1461        label_text_width_px(&label, base_font_size)
1462    };
1463    if text_width_px <= 0.0 || sx <= 0.0 {
1464        return gate_width;
1465    }
1466
1467    let required_px = text_width_px + 2.0 * LABEL_INNER_PADDING_PX;
1468    gate_width.max(required_px / sx)
1469}
1470
1471/// Split columns into folded rows using an order-preserving greedy strategy.
1472///
1473/// The algorithm keeps each row as full as possible under the computed width budget.
1474fn split_columns_by_fold(
1475    col_widths: &[f64],
1476    fold: i32,
1477    moment_spacing: f64,
1478    gate_width: f64,
1479) -> Vec<Vec<usize>> {
1480    if col_widths.is_empty() {
1481        return vec![Vec::new()];
1482    }
1483    if fold < 0 {
1484        return vec![(0..col_widths.len()).collect()];
1485    }
1486    let target_cols = usize::try_from(fold)
1487        .ok()
1488        .filter(|value| *value > 0)
1489        .unwrap_or(col_widths.len());
1490
1491    // Width budget derived from actual average column width (not only gate_width),
1492    // with a small slack to keep rows as long/compact as possible.
1493    let avg_col_width =
1494        col_widths.iter().copied().sum::<f64>() / (col_widths.len() as f64).max(1.0);
1495    let effective_col_width = avg_col_width.max(gate_width);
1496    let target_width = (2.0 * moment_spacing
1497        + target_cols as f64 * effective_col_width
1498        + target_cols.saturating_sub(1) as f64 * moment_spacing)
1499        * FOLD_TARGET_SLACK;
1500
1501    let mut rows = Vec::new();
1502    let mut start = 0usize;
1503    while start < col_widths.len() {
1504        let mut row = vec![start];
1505        let mut width = 2.0 * moment_spacing + col_widths[start];
1506        let mut next = start + 1;
1507        while next < col_widths.len() {
1508            let candidate = width + moment_spacing + col_widths[next];
1509            if candidate <= target_width {
1510                row.push(next);
1511                width = candidate;
1512                next += 1;
1513            } else {
1514                break;
1515            }
1516        }
1517        rows.push(row);
1518        start = next;
1519    }
1520    rows
1521}
1522
1523fn lane_to_y(lane: usize, y_base: f64) -> f64 {
1524    y_base + lane as f64 * WIRE_PITCH
1525}
1526
1527fn str_color(candidate: Option<&str>, fallback: &str) -> String {
1528    candidate.unwrap_or(fallback).to_string()
1529}
1530
1531fn normalize_style_color(candidate: Option<&str>) -> Option<String> {
1532    match candidate {
1533        Some(value)
1534            if value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("transparent") =>
1535        {
1536            None
1537        }
1538        Some(value) => Some(value.to_string()),
1539        None => None,
1540    }
1541}
1542
1543fn normalized_fill_color(style: &GateStyle, palette: &FigurePalette) -> Option<String> {
1544    normalize_style_color(style.background_color.as_deref())
1545        .or_else(|| normalize_style_color(palette.gate_fill_color))
1546}
1547
1548fn normalized_edge_color(style: &GateStyle, palette: &FigurePalette) -> Option<String> {
1549    normalize_style_color(style.border_color.as_deref())
1550        .or_else(|| normalize_style_color(Some(palette.gate_edge_color)))
1551}
1552
1553fn style_line_width(style: &GateStyle, palette: &FigurePalette) -> f64 {
1554    style.line_width.unwrap_or(palette.gate_linewidth)
1555}
1556
1557/// Resolve style key used to query `StyleBook`.
1558///
1559/// Priority:
1560/// 1. Primitive style categories (`M/R/D/B/CZ/SWAP`);
1561/// 2. Control-flow families (`IF/ELSE/END/WHILE/FOR/SWITCH`);
1562/// 3. Span-box module category (`MODULE`);
1563/// 4. Gate label fallback.
1564fn op_style_key(op: &VisualOperation) -> &str {
1565    match &op.style {
1566        VisualOpStyle::Measure => "M",
1567        VisualOpStyle::Reset => "R",
1568        VisualOpStyle::Delay => "D",
1569        VisualOpStyle::Barrier => "B",
1570        VisualOpStyle::Cz => "CZ",
1571        VisualOpStyle::Swap => "SWAP",
1572        VisualOpStyle::ControlFlow { kind } => control_flow_style_key(kind),
1573        VisualOpStyle::Gate if op.span_box => "MODULE",
1574        _ => op.label.as_str(),
1575    }
1576}
1577
1578fn control_flow_style_key(kind: &VisualControlFlowKind) -> &'static str {
1579    match kind {
1580        VisualControlFlowKind::IfElseBlock { .. } | VisualControlFlowKind::IfStart => "IF",
1581        VisualControlFlowKind::ElseStart => "ELSE",
1582        VisualControlFlowKind::WhileBlock { .. } | VisualControlFlowKind::WhileStart => "WHILE",
1583        VisualControlFlowKind::ForBlock { .. } | VisualControlFlowKind::ForStart => "FOR",
1584        VisualControlFlowKind::SwitchBlock { .. } | VisualControlFlowKind::SwitchStart => "SWITCH",
1585        VisualControlFlowKind::CaseStart => "CASE",
1586        VisualControlFlowKind::DefaultStart => "DEFAULT",
1587        VisualControlFlowKind::Break => "BREAK",
1588        VisualControlFlowKind::Continue => "CONTINUE",
1589        VisualControlFlowKind::End => "END",
1590    }
1591}