interactive_html_bom/
lib.rs

1//! Interactive HTML BOM Generator
2
3// Fail on warnings if feature "fail-on-warnings" is enabled.
4#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
5#![warn(missing_docs)]
6
7use jzon::{array, object, JsonValue};
8
9trait ToJson {
10  fn to_json(&self) -> JsonValue;
11}
12
13impl ToJson for usize {
14  fn to_json(&self) -> JsonValue {
15    (*self).into()
16  }
17}
18
19impl ToJson for String {
20  fn to_json(&self) -> JsonValue {
21    self.clone().into()
22  }
23}
24
25impl ToJson for (f32, f32) {
26  fn to_json(&self) -> JsonValue {
27    array![self.0, self.1]
28  }
29}
30
31impl<T: ToJson> ToJson for Vec<T> {
32  fn to_json(&self) -> JsonValue {
33    let mut arr = array![];
34    for item in self {
35      arr.push(item.to_json()).unwrap();
36    }
37    arr
38  }
39}
40
41/// View modes
42#[derive(Clone, PartialEq)]
43pub enum ViewMode {
44  /// BOM only
45  BomOnly,
46  /// BOM left, drawings right
47  LeftRight,
48  /// BOM top, drawings bottom
49  TopBottom,
50}
51
52impl ToJson for ViewMode {
53  fn to_json(&self) -> JsonValue {
54    match self {
55      ViewMode::BomOnly => "bom-only".into(),
56      ViewMode::LeftRight => "left-right".into(),
57      ViewMode::TopBottom => "top-bottom".into(),
58    }
59  }
60}
61
62/// Highlight pin-1 modes
63#[derive(Clone, PartialEq)]
64pub enum HighlightPin1Mode {
65  /// No pins
66  None,
67  /// Selected pins
68  Selected,
69  /// All pins
70  All,
71}
72
73impl ToJson for HighlightPin1Mode {
74  fn to_json(&self) -> JsonValue {
75    match self {
76      HighlightPin1Mode::None => "none".into(),
77      HighlightPin1Mode::Selected => "selected".into(),
78      HighlightPin1Mode::All => "all".into(),
79    }
80  }
81}
82
83/// Layer enum
84#[derive(Clone, PartialEq)]
85pub enum Layer {
86  /// Front layer
87  Front,
88  /// Back layer
89  Back,
90}
91
92impl ToJson for Layer {
93  fn to_json(&self) -> JsonValue {
94    match self {
95      Layer::Front => "F".into(),
96      Layer::Back => "B".into(),
97    }
98  }
99}
100
101/// Drawing kind
102#[derive(PartialEq)]
103pub enum DrawingKind {
104  /// Polygon
105  Polygon,
106  /// Component reference designator text
107  ReferenceText,
108  /// Component value text
109  ValueText,
110}
111
112/// Drawing layer
113#[derive(PartialEq)]
114pub enum DrawingLayer {
115  /// PCB edge
116  Edge,
117  /// Silkscreen front
118  SilkscreenFront,
119  /// Silkscreen back
120  SilkscreenBack,
121  /// Fabrication front
122  FabricationFront,
123  /// Fabrication back
124  FabricationBack,
125}
126
127/// Drawing structure (SVG polygon)
128#[non_exhaustive]
129pub struct Drawing {
130  kind: DrawingKind,
131  layer: DrawingLayer,
132  svgpath: String,
133  width: f32,
134  filled: bool,
135}
136
137impl Drawing {
138  /// Construct drawing
139  ///
140  /// # Arguments
141  ///
142  /// * `kind` - Drawing kind.
143  /// * `layer` - Drawing layer.
144  /// * `svgpath` - Outline as an SVG path \[mm\].
145  /// * `width` - Line width \[mm\].
146  /// * `filled` - Whether to fill the shape or not.
147  ///
148  /// # Returns
149  ///
150  /// Returns the new object.
151  pub fn new(
152    kind: DrawingKind,
153    layer: DrawingLayer,
154    svgpath: &str,
155    width: f32,
156    filled: bool,
157  ) -> Drawing {
158    Drawing {
159      kind,
160      layer,
161      svgpath: svgpath.to_owned(),
162      width,
163      filled,
164    }
165  }
166}
167
168impl ToJson for Drawing {
169  fn to_json(&self) -> JsonValue {
170    let mut obj = object! {
171      svgpath: self.svgpath.clone(),
172      filled: self.filled,
173    };
174    match self.kind {
175      DrawingKind::Polygon => {
176        obj["type"] = "polygon".into();
177        obj["width"] = self.width.into();
178      }
179      DrawingKind::ReferenceText => {
180        obj["thickness"] = self.width.into();
181        obj["ref"] = 1.into();
182      }
183      DrawingKind::ValueText => {
184        obj["thickness"] = self.width.into();
185        obj["val"] = 1.into();
186      }
187    }
188    obj
189  }
190}
191
192/// Track structure
193#[non_exhaustive]
194pub struct Track {
195  layer: Layer,
196  start: (f32, f32),
197  end: (f32, f32),
198  width: f32,
199  net: Option<String>,
200}
201
202impl Track {
203  /// Construct track
204  ///
205  /// # Arguments
206  ///
207  /// * `layer` - Layer.
208  /// * `start` - Start position (x, y) \[mm\].
209  /// * `end` - End position (x, y) \[mm\].
210  /// * `width` - Track width \[mm\].
211  /// * `net` - Net name (optional).
212  ///
213  /// # Returns
214  ///
215  /// Returns the new object.
216  pub fn new(
217    layer: Layer,
218    start: (f32, f32),
219    end: (f32, f32),
220    width: f32,
221    net: Option<&str>,
222  ) -> Track {
223    Track {
224      layer,
225      start,
226      end,
227      width,
228      net: net.map(|s| s.to_owned()),
229    }
230  }
231}
232
233impl ToJson for Track {
234  fn to_json(&self) -> JsonValue {
235    let mut obj = object! {
236      start: self.start.to_json(),
237      end: self.end.to_json(),
238      width: self.width,
239    };
240    if let Some(net) = &self.net {
241      obj["net"] = net.clone().into();
242    }
243    obj
244  }
245}
246
247/// Via structure
248#[non_exhaustive]
249pub struct Via {
250  layers: Vec<Layer>,
251  pos: (f32, f32),
252  diameter: f32,
253  drill_diameter: f32,
254  net: Option<String>,
255}
256
257impl Via {
258  /// Construct via
259  ///
260  /// # Arguments
261  ///
262  /// * `layers` - Layers.
263  /// * `pos` - Position (x, y) \[mm\].
264  /// * `diameter` - Outer diameter \[mm\].
265  /// * `drill_diameter` - Drill diameter \[mm\].
266  /// * `net` - Net name (optional).
267  ///
268  /// # Returns
269  ///
270  /// Returns the new object.
271  pub fn new(
272    layers: &[Layer],
273    pos: (f32, f32),
274    diameter: f32,
275    drill_diameter: f32,
276    net: Option<&str>,
277  ) -> Via {
278    Via {
279      layers: layers.to_vec(),
280      pos,
281      diameter,
282      drill_diameter,
283      net: net.map(|s| s.to_owned()),
284    }
285  }
286}
287
288impl ToJson for Via {
289  fn to_json(&self) -> JsonValue {
290    let mut obj = object! {
291      start: self.pos.to_json(),
292      end: self.pos.to_json(),
293      width: self.diameter,
294      drillsize: self.drill_diameter,
295    };
296    if let Some(net) = &self.net {
297      obj["net"] = net.clone().into();
298    }
299    obj
300  }
301}
302
303/// Zone structure
304#[non_exhaustive]
305pub struct Zone {
306  layer: Layer,
307  svgpath: String,
308  net: Option<String>,
309}
310
311impl Zone {
312  /// Construct object
313  ///
314  /// # Arguments
315  ///
316  /// * `layer` - Layer.
317  /// * `svgpath` - Zone outline as SVG path \[mm\].
318  /// * `net` - Net name (optional).
319  ///
320  /// # Returns
321  ///
322  /// Returns the new object.
323  pub fn new(layer: Layer, svgpath: &str, net: Option<&str>) -> Zone {
324    Zone {
325      layer,
326      svgpath: svgpath.to_owned(),
327      net: net.map(|s| s.to_owned()),
328    }
329  }
330}
331
332impl ToJson for Zone {
333  fn to_json(&self) -> JsonValue {
334    let mut obj = object! {
335      svgpath: self.svgpath.clone(),
336    };
337    if let Some(net) = &self.net {
338      obj["net"] = net.clone().into();
339    }
340    obj
341  }
342}
343
344/// Footprint pad structure
345#[derive(Clone)]
346#[non_exhaustive]
347pub struct Pad {
348  layers: Vec<Layer>,
349  pos: (f32, f32),
350  angle: f32,
351  svgpath: String,
352  drill_size: Option<(f32, f32)>,
353  net: Option<String>,
354  pin1: bool,
355}
356
357impl Pad {
358  /// Construct object
359  ///
360  /// # Arguments
361  ///
362  /// * `layers` - Layers on which the pad exists.
363  /// * `pos` - Position (x, y) \[mm\].
364  /// * `angle` - Rotation angle [°].
365  /// * `svgpath` - Pad shape as SVG path \[mm\].
366  /// * `drill_size` - Drill size (w, h) \[mm\] (only for THT pads).
367  /// * `net` - Net name (optional).
368  /// * `pin1` - Whether this is considered as the pin-1 or not.
369  ///
370  /// # Returns
371  ///
372  /// Returns the new object.
373  pub fn new(
374    layers: &[Layer],
375    pos: (f32, f32),
376    angle: f32,
377    svgpath: &str,
378    drill_size: Option<(f32, f32)>,
379    net: Option<&str>,
380    pin1: bool,
381  ) -> Pad {
382    Pad {
383      layers: layers.into(),
384      pos,
385      angle,
386      svgpath: svgpath.to_owned(),
387      drill_size,
388      net: net.map(|s| s.to_owned()),
389      pin1,
390    }
391  }
392}
393
394impl ToJson for Pad {
395  fn to_json(&self) -> JsonValue {
396    let mut obj = object! {
397      layers: self.layers.to_json(),
398      pos: self.pos.to_json(),
399      angle: self.angle,
400      shape: "custom",
401      svgpath: self.svgpath.clone(),
402    };
403    if let Some(drill) = &self.drill_size {
404      obj["type"] = "th".into();
405      obj["drillsize"] = array![drill.0, drill.1];
406      obj["drillshape"] = if drill.0 != drill.1 {
407        "oblong".into()
408      } else {
409        "circle".into()
410      };
411    } else {
412      obj["type"] = "smd".into();
413    }
414    if let Some(net) = &self.net {
415      obj["net"] = net.clone().into();
416    }
417    if self.pin1 {
418      obj["pin1"] = 1.into();
419    }
420    obj
421  }
422}
423
424/// Footprint structure
425#[non_exhaustive]
426pub struct Footprint {
427  layer: Layer,
428  pos: (f32, f32),
429  angle: f32,
430  bottom_left: (f32, f32),
431  top_right: (f32, f32),
432  fields: Vec<String>,
433  pads: Vec<Pad>,
434  mount: bool,
435}
436
437impl Footprint {
438  /// Construct object
439  ///
440  /// # Arguments
441  ///
442  /// * `layer` - Placement layer.
443  /// * `pos` - Position (x, y) \[mm\].
444  /// * `angle` - Rotation angle [°].
445  /// * `bottom_left` - Bottom left corner of bounding box (x, y) \[mm\].
446  /// * `top_right` - Top right corner of bounding box (x, y) \[mm\].
447  /// * `fields` - Custom fields, corresponding to [InteractiveHtmlBom::fields].
448  /// * `pads` - Footprint pads.
449  /// * `mount` - Whether the footprint is mounted or not.
450  ///
451  /// # Returns
452  ///
453  /// Returns the new object.
454  #[allow(clippy::too_many_arguments)]
455  pub fn new(
456    layer: Layer,
457    pos: (f32, f32),
458    angle: f32,
459    bottom_left: (f32, f32),
460    top_right: (f32, f32),
461    fields: &[String],
462    pads: &[Pad],
463    mount: bool,
464  ) -> Footprint {
465    Footprint {
466      layer,
467      pos,
468      angle,
469      bottom_left,
470      top_right,
471      fields: fields.to_vec(),
472      pads: pads.to_vec(),
473      mount,
474    }
475  }
476}
477
478impl ToJson for Footprint {
479  fn to_json(&self) -> JsonValue {
480    object! {
481      bbox: object!{
482        pos: self.pos.to_json(),
483        angle: self.angle,
484        relpos: self.bottom_left.to_json(),
485        size: array![
486          self.top_right.0 - self.bottom_left.0,
487          self.top_right.1 - self.bottom_left.1],
488      },
489      drawings: array![],  // Not supported yet.
490      layer: self.layer.to_json(),
491      pads: self.pads.to_json(),
492    }
493  }
494}
495
496/// Reference-FootprintID map
497#[derive(Clone)]
498#[non_exhaustive]
499pub struct RefMap {
500  reference: String,
501  footprint_id: usize,
502}
503
504impl RefMap {
505  /// Construct object
506  ///
507  /// # Arguments
508  ///
509  /// * `reference` - Component reference (e.g. "R1").
510  /// * `footprint_id` - ID of footprint as returned by
511  ///                    [InteractiveHtmlBom::add_footprint].
512  ///
513  /// # Returns
514  ///
515  /// Returns the new object.
516  pub fn new(reference: &str, footprint_id: usize) -> RefMap {
517    RefMap {
518      reference: reference.to_owned(),
519      footprint_id,
520    }
521  }
522}
523
524impl ToJson for RefMap {
525  fn to_json(&self) -> JsonValue {
526    array! {
527      self.reference.clone(),
528      self.footprint_id,
529    }
530  }
531}
532
533/// Interactive HTML BOM structure
534///
535/// The top-level structure to build & generate a HTML BOM.
536///
537/// <div class="warning">
538/// Please note that this struct is not completely fool-proof as it does not
539/// validate lots of the added data. So make sure you add only valid BOM data.
540/// Only the most important things are validated to avoid generating completely
541/// broken HTML pages: Footprint IDs in BOM rows, and number of fields in
542/// footprints.
543/// </div>
544///
545/// # Examples
546///
547/// ```
548/// use interactive_html_bom::*;
549///
550/// let mut ibom = InteractiveHtmlBom::new(
551///   "My Project",   // Title
552///   "My Company",   // Company
553///   "Rev. 1",       // Revision
554///   "1970-01-01",   // Date
555///   (0.0, 0.0),     // Bottom left
556///   (100.0, 80.0),  // Top right
557/// );
558///
559/// // Set configuration.
560/// ibom.fields = vec!["Value".into(), "Footprint".into()];
561///
562/// // Draw PCB.
563/// ibom.drawings.push(Drawing::new(
564///   DrawingKind::Polygon,             // Kind of drawing
565///   DrawingLayer::Edge,               // Layer
566///   "M 0 0 H 100 V 80 H -100 V -80",  // SVG path
567///   0.1,                              // Line width
568///   false,                            // Filled
569/// ));
570/// ibom.drawings.push(Drawing::new(
571///   DrawingKind::ReferenceText,
572///   DrawingLayer::SilkscreenFront,
573///   "M 10 10 H 80 V 60 H -80 V -60",
574///   0.1,
575///   false,
576/// ));
577///
578/// // Add footprints.
579/// let id = ibom.add_footprint(
580///   Footprint::new(
581///     Layer::Front,                       // Layer
582///     (50.0, 40.0),                       // Position
583///     45.0,                               // Rotation
584///     (-2.0, -1.0),                       // Bottom left
585///     (2.0, 1.0),                         // Top right
586///     &["100R".into(), "0603".into()],    // Fields
587///     &[Pad::new(
588///         &[Layer::Front],                // Pad layers
589///         (-2.0, 0.0),                    // Pad position
590///         0.0,                            // Pad rotation
591///         "M -1 -1 H 2 V 2 H -2 V -2",    // Pad shape (SVG)
592///         None,                           // Pad drill
593///         None,                           // Pad net
594///         true,                           // Pin 1
595///       ),
596///       // [...]
597///     ],
598///     true,                               // Mount or not
599///   ),
600/// );
601///
602/// // Add BOM rows (designators and their footprint IDs).
603/// ibom.bom_front.push(vec![RefMap::new("R1", id)]);
604/// ```
605#[non_exhaustive]
606pub struct InteractiveHtmlBom {
607  // Metadata
608  title: String,
609  company: String,
610  revision: String,
611  date: String,
612  bottom_left: (f32, f32),
613  top_right: (f32, f32),
614
615  /// Initial view mode
616  pub view_mode: ViewMode,
617
618  /// Hightlight pin-1 mode
619  pub highlight_pin1: HighlightPin1Mode,
620
621  /// Dark mode on/off
622  pub dark_mode: bool,
623
624  /// Board drawings rotation \[°\]
625  pub board_rotation: f32,
626
627  /// Whether to offset the back side rotation or not
628  pub offset_back_rotation: bool,
629
630  /// Silkscreen visibility
631  pub show_silkscreen: bool,
632
633  /// Fabrication layer visibility
634  pub show_fabrication: bool,
635
636  /// Pads visibility
637  pub show_pads: bool,
638
639  /// Checkbox column names
640  pub checkboxes: Vec<String>,
641
642  /// Custom field names, listed as columns
643  pub fields: Vec<String>,
644
645  /// User-defined HTML header
646  ///
647  /// <div class="warning">
648  /// This should be used carefully as we neither guarantee forward- nor
649  /// backward-compatibility.
650  /// </div>
651  pub user_header: String,
652
653  /// User-defined HTML footer
654  ///
655  /// <div class="warning">
656  /// This should be used carefully as we neither guarantee forward- nor
657  /// backward-compatibility.
658  /// </div>
659  pub user_footer: String,
660
661  /// User-defined JavaScript
662  ///
663  /// <div class="warning">
664  /// This should be used carefully as we neither guarantee forward- nor
665  /// backward-compatibility.
666  /// </div>
667  pub user_js: String,
668
669  /// Drawings (PCB edges, silkscreen, fabrication)
670  pub drawings: Vec<Drawing>,
671
672  /// PCB tracks
673  pub tracks: Vec<Track>,
674
675  /// PCB vias
676  pub vias: Vec<Via>,
677
678  /// PCB zones
679  pub zones: Vec<Zone>,
680
681  /// Footprints
682  pub footprints: Vec<Footprint>,
683
684  /// BOM rows front side
685  pub bom_front: Vec<Vec<RefMap>>,
686
687  /// BOM rows back side
688  pub bom_back: Vec<Vec<RefMap>>,
689
690  /// BOM rows front+back
691  pub bom_both: Vec<Vec<RefMap>>,
692}
693
694impl InteractiveHtmlBom {
695  /// Construct object
696  ///
697  /// # Arguments
698  ///
699  /// * `title` - Project title.
700  /// * `company` - Company/author name.
701  /// * `revision` - Project revision.
702  /// * `date` - Date/time as desired.
703  /// * `bottom_left` - Bottom left corner of bounding box (x, y) \[mm\].
704  /// * `top_right` - Top right corner of bounding box (x, y) \[mm\].
705  ///
706  /// # Returns
707  ///
708  /// Returns the new object.
709  pub fn new(
710    title: &str,
711    company: &str,
712    revision: &str,
713    date: &str,
714    bottom_left: (f32, f32),
715    top_right: (f32, f32),
716  ) -> InteractiveHtmlBom {
717    InteractiveHtmlBom {
718      title: title.to_owned(),
719      revision: revision.to_owned(),
720      company: company.to_owned(),
721      date: date.to_owned(),
722      bottom_left,
723      top_right,
724      view_mode: ViewMode::LeftRight,
725      highlight_pin1: HighlightPin1Mode::None,
726      dark_mode: false,
727      board_rotation: 0.0,
728      offset_back_rotation: false,
729      show_silkscreen: true,
730      show_fabrication: true,
731      show_pads: true,
732      checkboxes: vec!["Sourced".into(), "Placed".into()],
733      fields: Vec::new(),
734      user_js: String::new(),
735      user_header: String::new(),
736      user_footer: String::new(),
737      drawings: Vec::new(),
738      tracks: Vec::new(),
739      vias: Vec::new(),
740      zones: Vec::new(),
741      footprints: Vec::new(),
742      bom_front: Vec::new(),
743      bom_back: Vec::new(),
744      bom_both: Vec::new(),
745    }
746  }
747
748  /// Add footprint
749  ///
750  /// # Arguments
751  ///
752  /// * `fpt` - The footprint to add.
753  ///
754  /// # Returns
755  ///
756  /// Returns the ID of the added footprint, to be used for referencing it
757  /// in BOM rows.
758  pub fn add_footprint(&mut self, fpt: Footprint) -> usize {
759    self.footprints.push(fpt);
760    self.footprints.len() - 1
761  }
762
763  /// Generate HTML
764  pub fn generate_html(&self) -> Result<String, String> {
765    // Validate footprint IDs.
766    for bom in [&self.bom_back, &self.bom_front, &self.bom_both] {
767      for row in bom {
768        for map in row {
769          if map.footprint_id >= self.footprints.len() {
770            return Err("Invalid footprint ID.".into());
771          }
772        }
773      }
774    }
775
776    // Calculate some additional data.
777    let mut nets = Vec::new();
778    let mut dnp_footprint_ids: Vec<usize> = Vec::new();
779    for (index, footprint) in self.footprints.iter().enumerate() {
780      if !footprint.mount {
781        dnp_footprint_ids.push(index);
782      }
783      for pad in &footprint.pads {
784        if let Some(net) = &pad.net {
785          if !nets.contains(net) {
786            nets.push(net.clone());
787          }
788        }
789      }
790    }
791
792    // Auto-detect visibility of front/back sides depending on BOM.
793    let layer_view = if !self.bom_front.is_empty() && self.bom_back.is_empty() {
794      "F"
795    } else if self.bom_front.is_empty() && !self.bom_back.is_empty() {
796      "B"
797    } else {
798      "FB"
799    };
800
801    let config = object! {
802        board_rotation: (self.board_rotation / 5.0) as i32,
803        bom_view: self.view_mode.to_json(),
804        checkboxes: self.checkboxes.join(","),
805        dark_mode: self.dark_mode,
806        fields: self.fields.to_json(),
807        highlight_pin1: self.highlight_pin1.to_json(),
808        kicad_text_formatting: false,
809        layer_view: layer_view,
810        offset_back_rotation: self.offset_back_rotation,
811        redraw_on_drag: true,
812        show_fabrication: self.show_fabrication,
813        show_pads: self.show_pads,
814        show_silkscreen: self.show_silkscreen,
815    };
816
817    let mut data = object! {
818      ibom_version: String::from_utf8_lossy(include_bytes!("web/version.txt")).to_string(),
819      metadata: object!{
820        title: self.title.clone(),
821        company: self.company.clone(),
822        revision: self.revision.clone(),
823        date: self.date.clone(),
824      },
825      edges_bbox: object!{
826        minx: self.bottom_left.0,
827        maxx: self.top_right.0,
828        miny: self.bottom_left.1,
829        maxy: self.top_right.1,
830      },
831      edges: self.drawings.iter()
832        .filter(|x| x.layer == DrawingLayer::Edge)
833        .map(ToJson::to_json).collect::<Vec<_>>(),
834      drawings: object!{
835        silkscreen: object!{
836          F: self.drawings.iter()
837              .filter(|x| x.layer == DrawingLayer::SilkscreenFront)
838              .map(ToJson::to_json).collect::<Vec<_>>(),
839          B: self.drawings.iter()
840              .filter(|x| x.layer == DrawingLayer::SilkscreenBack)
841              .map(ToJson::to_json).collect::<Vec<_>>(),
842        },
843        fabrication: object!{
844          F: self.drawings.iter()
845              .filter(|x| x.layer == DrawingLayer::FabricationFront)
846              .map(ToJson::to_json).collect::<Vec<_>>(),
847          B: self.drawings.iter()
848              .filter(|x| x.layer == DrawingLayer::FabricationBack)
849              .map(ToJson::to_json).collect::<Vec<_>>(),
850        },
851      },
852      tracks: object!{
853        F: self.tracks.iter()
854            .filter(|x| x.layer == Layer::Front)
855            .map(ToJson::to_json)
856            .chain(self.vias.iter()
857              .filter(|x| x.layers.contains(&Layer::Front))
858              .map(ToJson::to_json))
859            .collect::<Vec<_>>(),
860        B: self.tracks.iter()
861            .filter(|x| x.layer == Layer::Back)
862            .map(ToJson::to_json)
863            .chain(self.vias.iter()
864              .filter(|x| x.layers.contains(&Layer::Back))
865              .map(ToJson::to_json))
866            .collect::<Vec<_>>(),
867      },
868      zones: object!{
869        F: self.zones.iter()
870            .filter(|x| x.layer == Layer::Front)
871            .map(ToJson::to_json).collect::<Vec<_>>(),
872        B: self.zones.iter()
873            .filter(|x| x.layer == Layer::Back)
874            .map(ToJson::to_json).collect::<Vec<_>>(),
875      },
876      nets: nets.to_json(),
877      footprints: self.footprints.to_json(),
878      bom: object!{
879        F: self.bom_front.to_json(),
880        B: self.bom_back.to_json(),
881        both: self.bom_both.to_json(),
882        skipped: dnp_footprint_ids.to_json(),
883        fields: object!{},  // Filled below.
884      },
885    };
886
887    // Fill in footprint fields and check their length.
888    for (id, fpt) in self.footprints.iter().enumerate() {
889      if fpt.fields.len() != self.fields.len() {
890        return Err("Inconsistent number of fields.".into());
891      }
892      data["bom"]["fields"][id.to_string()] = fpt.fields.to_json();
893    }
894
895    // Build JS variables.
896    let config_str = "var config = ".to_owned() + &config.dump();
897    let pcbdata_str =
898      "var pcbdata = JSON.parse(LZString.decompressFromBase64(\"".to_owned()
899        + &lz_str::compress_to_base64(&data.dump())
900        + "\"))";
901
902    // Load HTML.
903    let mut html =
904      String::from_utf8_lossy(include_bytes!("web/ibom.html")).to_string();
905
906    // Replace placeholders.
907    let replacements = [
908      (
909        "///CSS///",
910        String::from_utf8_lossy(include_bytes!("web/ibom.css")),
911      ),
912      (
913        "///SPLITJS///",
914        String::from_utf8_lossy(include_bytes!("web/split.js")),
915      ),
916      (
917        "///LZ-STRING///",
918        String::from_utf8_lossy(include_bytes!("web/lz-string.js")),
919      ),
920      (
921        "///POINTER_EVENTS_POLYFILL///",
922        String::from_utf8_lossy(include_bytes!("web/pep.js")),
923      ),
924      (
925        "///UTILJS///",
926        String::from_utf8_lossy(include_bytes!("web/util.js")),
927      ),
928      (
929        "///RENDERJS///",
930        String::from_utf8_lossy(include_bytes!("web/render.js")),
931      ),
932      (
933        "///TABLEUTILJS///",
934        String::from_utf8_lossy(include_bytes!("web/table-util.js")),
935      ),
936      (
937        "///IBOMJS///",
938        String::from_utf8_lossy(include_bytes!("web/ibom.js")),
939      ),
940      ("///CONFIG///", config_str.as_str().into()),
941      ("///PCBDATA///", pcbdata_str.as_str().into()),
942      ("///USERJS///", self.user_js.as_str().into()),
943      ("///USERHEADER///", self.user_header.as_str().into()),
944      ("///USERFOOTER///", self.user_footer.as_str().into()),
945    ];
946    for replacement in &replacements {
947      html = html.replace(replacement.0, &replacement.1);
948    }
949    Ok(html)
950  }
951}