commit 976c3d0679d9665d1c9ce8d6944029ffffc26e10
Author: Nathan Anderson <n8r@tuta.io>
Date:   Sat Sep 7 14:42:32 2024 -0600

    WIP hot reloading enabled, much progress

diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3a30b03
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# https://dart.dev/guides/libraries/private-files
+# Created by `dart pub`
+.dart_tool/
+.direnv/**
+
+*.toml
+*.pdf
+*.jpeg
+*.png
+*.JPEG
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..effe43c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 1.0.0
+
+- Initial version.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3816eca
--- /dev/null
+++ b/README.md
@@ -0,0 +1,2 @@
+A sample command-line application with an entrypoint in `bin/`, library code
+in `lib/`, and example unit test in `test/`.
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..214c536
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,12 @@
+include: package:lint/strict.yaml
+
+# linter:
+#   rules:
+#     - camel_case_types
+analyzer:
+  language:
+    strict-casts: true
+    strict-inference: true
+    strict-raw-types: true
+  exclude:
+    # - path/to/excluded/files/**
diff --git a/bin/dartboard_resume.dart b/bin/dartboard_resume.dart
new file mode 100644
index 0000000..c314058
--- /dev/null
+++ b/bin/dartboard_resume.dart
@@ -0,0 +1,47 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:dartboard_resume/render.dart';
+import 'package:hotreloader/hotreloader.dart';
+import 'package:logging/logging.dart' as logging;
+
+StreamSubscription<FileSystemEvent>? fileStreamSub;
+StreamSubscription<String>? stdinStreamSub;
+
+Future<void> main(List<String> arguments) async {
+  const String tomlFilePath = "resume.toml";
+  logging.hierarchicalLoggingEnabled = true;
+  HotReloader.logLevel = logging.Level.INFO;
+  final HotReloader reloader = await HotReloader.create();
+
+  stdin.lineMode = false;
+  stdin.echoMode = false;
+  stdin.echoNewlineMode = false;
+  stdinStreamSub = stdin.transform(const Utf8Decoder()).transform(const LineSplitter()).listen(
+    (event) {
+      if (event == "r") {
+        stdout.writeln("Triggering pdf render...");
+        renderPdf(tomlFilePath, force: true);
+      }
+    },
+  );
+
+  ProcessSignal.sigint.watch().listen((_) {
+    stdout.writeln('SIGINT received. Exiting gracefully...');
+    fileStreamSub?.cancel();
+    stdinStreamSub?.cancel();
+    // Perform cleanup or other necessary actions here
+    reloader.stop();
+    exit(0); // Exit with code 0 to indicate a successful termination
+  });
+  if (FileSystemEntity.isWatchSupported) {
+    final fileStream = File(tomlFilePath).watch(events: FileSystemEvent.modify);
+    fileStreamSub = fileStream.listen((e) {
+      renderPdf(tomlFilePath);
+    });
+    stdout.writeln('Watching for file changes.');
+  } else {
+    renderPdf(tomlFilePath);
+  }
+}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..1792a68
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+  "nodes": {
+    "flake-utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1710146030,
+        "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1719848872,
+        "narHash": "sha256-H3+EC5cYuq+gQW8y0lSrrDZfH71LB4DAf+TDFyvwCNA=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "00d80d13810dbfea8ab4ed1009b09100cca86ba8",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": "nixpkgs"
+      }
+    },
+    "systems": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..18a1015
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,25 @@
+{
+  description = "Simple dart flake";
+  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+  inputs.flake-utils.url = "github:numtide/flake-utils";
+
+  outputs = {
+    flake-utils,
+    nixpkgs,
+    ...
+  }:
+    flake-utils.lib.eachDefaultSystem (system: let
+      pkgs = import nixpkgs {
+        inherit system;
+      };
+    in {
+      devShell = pkgs.mkShell {
+          buildInputs = with pkgs; [
+            dart
+          ];
+          # shellHook = ''
+          #   export FLUTTER_ROOT
+          # '';
+        };
+    });
+}
diff --git a/lib/dartboard_parser.dart b/lib/dartboard_parser.dart
new file mode 100644
index 0000000..65783eb
--- /dev/null
+++ b/lib/dartboard_parser.dart
@@ -0,0 +1,281 @@
+import 'dart:io';
+
+import 'package:intl/intl.dart';
+import 'package:pdf/pdf.dart';
+import 'package:pdf/widgets.dart';
+import 'package:toml/toml.dart';
+
+class DartboardData {
+  DartboardData({
+    required this.fullName,
+    required this.phoneNumber,
+    required this.email,
+    // required this.address,
+    required this.imagePath,
+    required this.dartboardTheme,
+    required this.experiences,
+    required this.miscList,
+  });
+
+  factory DartboardData.fromToml(String tomlFilePath) {
+    final tomlData = TomlDocument.loadSync(tomlFilePath).toMap();
+
+    final List<DartboardExperience> exps = tomlData.entries
+        .where((e) => e.value is List && (e.value as List).firstOrNull is Map)
+        .map((MapEntry<String, dynamic> expsEntry) {
+          final String subsection = tomlData['${expsEntry.key}_name'] as String? ?? '';
+          final exps = (expsEntry.value as List).map((e) => e as Map<String, dynamic>).toList();
+          return exps.map((exp) {
+            final TomlLocalDate? start = exp['start'] as TomlLocalDate?;
+            final TomlLocalDate? end = exp['end'] as TomlLocalDate?;
+            return DartboardExperience(
+              title: exp['title'] as String,
+              subsection: subsection,
+              range: DateRange(
+                start: start == null ? null : DateTime(start.date.year, start.date.month),
+                end: end == null ? null : DateTime(end.date.year, end.date.month),
+              ),
+              attributes: (exp['attributes'] as List).map((e) => DartboardText(content: e as String)).toList(),
+              location: exp['location'] as String?,
+            );
+          }).toList();
+        })
+        .expand((i) => i)
+        .toList();
+    return DartboardData(
+      fullName: tomlData['full_name'] as String,
+      phoneNumber: tomlData['phone_number'] as String,
+      email: tomlData['email'] as String,
+      imagePath: tomlData['image'] as String,
+      dartboardTheme: DartboardTheme.fromToml(tomlData),
+      experiences: exps,
+      miscList: [
+        DartboardList(subsection: 'Skills & Interest', content: 'Proficient in flutter and other'),
+      ],
+    );
+  }
+
+  final String fullName;
+  final String phoneNumber;
+  final String email;
+  // final String address;
+  final String imagePath;
+  final DartboardTheme dartboardTheme;
+  final List<DartboardExperience> experiences;
+  final List<DartboardList> miscList;
+
+  Font get font => dartboardTheme.font;
+
+  PdfColor get primaryColor => dartboardTheme.primaryColor;
+  PdfColor get accentColor => dartboardTheme.accentColor;
+  PdfColor get backgroundColor => dartboardTheme.backgroundColor;
+
+  TextStyle get headerTextStyle =>
+      TextStyle(fontSize: 18, font: font, fontWeight: FontWeight.bold, color: const PdfColorGrey(0.18));
+  TextStyle get subheaderTextStyle => TextStyle(fontSize: 14, font: font, color: const PdfColorGrey(0.3));
+  TextStyle get defaultTextStyle => TextStyle(fontSize: 10, font: font, color: const PdfColorGrey(0.45));
+
+  Map<String, List<DartboardExperience>> get groupedExperiences {
+    final Map<String, List<DartboardExperience>> exps = {};
+    for (final DartboardExperience exp in experiences) {
+      if (!exps.containsKey(exp.subsection)) {
+        exps[exp.subsection] = <DartboardExperience>[];
+      }
+      exps[exp.subsection]!.add(exp);
+    }
+    return exps;
+  }
+
+  @override
+  int get hashCode {
+    return Object.hashAll([fullName, phoneNumber, email, imagePath, ...experiences]);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return super.hashCode == other.hashCode;
+  }
+}
+
+class DartboardExperience {
+  DartboardExperience({
+    required this.title,
+    required this.attributes,
+    required this.range,
+    required this.subsection,
+    this.location,
+  });
+  final String subsection;
+  final String title;
+  String? location;
+  final List<DartboardText> attributes;
+  final DateRange range;
+
+  @override
+  int get hashCode {
+    return Object.hashAll([subsection, title, ...attributes, range]);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return super.hashCode == other.hashCode;
+  }
+}
+
+class DateRange {
+  DateRange({required this.start, required this.end});
+  final DateTime? start;
+  final DateTime? end;
+
+  @override
+  String toString() {
+    final DateFormat dateFormat = DateFormat("MMM yyyy");
+    if (start == null && end == null) {
+      return '';
+    }
+    if (start == null && end != null) {
+      return dateFormat.format(end!);
+    }
+    if (start != null && end == null) {
+      return "${dateFormat.format(start!)} - Current";
+    }
+    return "${dateFormat.format(start!)} - ${dateFormat.format(end!)}";
+  }
+
+  @override
+  int get hashCode {
+    return Object.hashAll([start, end]);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return super.hashCode == other.hashCode;
+  }
+}
+
+class DartboardList {
+  DartboardList({required this.subsection, required this.content});
+  final String subsection;
+  final String content;
+  @override
+  int get hashCode {
+    return Object.hashAll([subsection, content]);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return super.hashCode == other.hashCode;
+  }
+}
+
+class DartboardTheme {
+  DartboardTheme({
+    required this.primaryHex,
+    required this.accentHex,
+    required this.backgroundHex,
+    required this.fontPath,
+    required this.bulletPoint,
+  });
+
+  factory DartboardTheme.fromToml(Map<String, dynamic> toml) => DartboardTheme(
+        primaryHex: (toml['theme'] as Map)['primary_hex'] as String,
+        accentHex: (toml['theme'] as Map)['accent_hex'] as String,
+        backgroundHex: (toml['theme'] as Map)['background_hex'] as String,
+        fontPath: (toml['theme'] as Map)['font'] as String,
+        bulletPoint: (toml['theme'] as Map)['bullet_point'] as String? ?? '-',
+      );
+
+  factory DartboardTheme.retro() => DartboardTheme(
+        primaryHex: "00AA00",
+        accentHex: "44EE66",
+        backgroundHex: "FFFFFF",
+        fontPath: "nerd.ttf",
+        bulletPoint: '-',
+      );
+
+  static const double inch = 72.0;
+  static const double cm = inch / 2.54;
+  static const double mm = inch / 25.4;
+
+  static const width = 21.0 * cm;
+  static const height = 29.7 * cm;
+  static const margin = 2.0 * cm;
+
+  final String primaryHex;
+  final String accentHex;
+  final String backgroundHex;
+  final String fontPath;
+  final String bulletPoint;
+  Font? _font;
+
+  PdfColor get primaryColor => PdfColor.fromHex(primaryHex);
+  PdfColor get accentColor => PdfColor.fromHex(accentHex);
+  PdfColor get backgroundColor => PdfColor.fromHex(backgroundHex);
+
+  Font get font {
+    _font ??= Font.ttf(File(fontPath).readAsBytesSync().buffer.asByteData());
+    return _font!;
+  }
+
+  @override
+  int get hashCode {
+    return Object.hashAll([primaryHex, accentHex, backgroundHex, fontPath, bulletPoint]);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return super.hashCode == other.hashCode;
+  }
+}
+
+enum DartboardTextType { normal, linkText }
+
+typedef DartboardTextLinkData = ({String text, String? url, DartboardTextType type});
+
+/// Automatically detects and parses strings with hypertext in a markdown format
+class DartboardText {
+  DartboardText({required this.content});
+
+  final String content;
+  static final _markdownLinkRegex = RegExp(r'\[(.*?)\]\((.*?)\)');
+
+  bool get hasLinkMarkup => _markdownLinkRegex.hasMatch(content);
+
+  List<DartboardTextLinkData> toTextLinkList() {
+    final markdownLinkRegex = RegExp(r'\[(.*?)\]\((.*?)\)');
+    if (markdownLinkRegex.hasMatch(content)) {
+      final matches = markdownLinkRegex.allMatches(content).toList();
+      final List<DartboardTextLinkData> stringSections = [];
+      int prevStartIndex = 0;
+      while (matches.isNotEmpty) {
+        final match = matches.removeAt(0);
+        stringSections
+            .add((text: content.substring(prevStartIndex, match.start), url: null, type: DartboardTextType.normal));
+        stringSections.add((text: match.group(1)!, url: match.group(2), type: DartboardTextType.linkText));
+        prevStartIndex = match.end;
+      }
+      stringSections
+          .add((text: content.substring(prevStartIndex, content.length), url: null, type: DartboardTextType.normal));
+      return stringSections;
+    } else {
+      return [(text: content, url: null, type: DartboardTextType.normal)];
+      // return Text("${bulletString != null ? '$bulletString ' : ''}$content", style: style);
+    }
+  }
+
+  @override
+  int get hashCode {
+    return Object.hashAll([content]);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return super.hashCode == other.hashCode;
+  }
+}
+
+String _getSubsectionFromKey(String key) {
+  switch (key) {
+    case 'exp':
+  }
+}
diff --git a/lib/dartboard_resume.dart b/lib/dartboard_resume.dart
new file mode 100644
index 0000000..188c842
--- /dev/null
+++ b/lib/dartboard_resume.dart
@@ -0,0 +1,101 @@
+import 'dart:io';
+
+import 'package:dartboard_resume/dartboard_parser.dart';
+import 'package:dartboard_resume/dartboard_widgets.dart';
+import 'package:pdf/pdf.dart';
+import 'package:pdf/widgets.dart';
+
+Page generatePdfPage({required DartboardData dartboardData, required int renderNs}) {
+  final List<Widget> groupedExperienceList = dartboardData.groupedExperiences.entries.map<Widget>(
+    (entry) {
+      final String subsection = entry.key;
+      final List<DartboardExperience> experiences = entry.value;
+      return Column(
+        children: [
+          Row(
+            children: [
+              Text(
+                subsection,
+                style: dartboardData.subheaderTextStyle
+                    .merge(const TextStyle(fontSize: 18))
+                    .apply(color: const PdfColorGrey(0.2)),
+              ),
+            ],
+          ),
+          Container(height: 2, width: 200, color: const PdfColorGrey(0.7)),
+          ...experiences.map(
+            (DartboardExperience exp) => Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Row(
+                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                  children: [
+                    Text(exp.title, style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.3))),
+                    Text(
+                      exp.range.toString(),
+                      style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.42)),
+                    ),
+                  ],
+                ),
+                ...exp.attributes.map(
+                  (a) => Text(
+                    "${dartboardData.dartboardTheme.bulletPoint} $a",
+                    style: dartboardData.defaultTextStyle.apply(
+                      color: const PdfColorGrey(0.55),
+                    ),
+                  ),
+                ),
+                SizedBox(height: 20),
+              ],
+            ),
+          ),
+          DartboardFooter(dartboardData: dartboardData, renderNs: renderNs),
+        ],
+      );
+    },
+  ).toList();
+  return Page(
+    pageTheme: const PageTheme(pageFormat: PdfPageFormat.standard),
+    build: (Context context) {
+      return Column(
+        children: [
+          SizedBox(
+            height: 120,
+            width: double.infinity,
+            child: Stack(
+              children: [
+                Positioned(
+                  left: 0,
+                  child: Container(
+                    height: 100,
+                    width: 100,
+                    decoration: BoxDecoration(
+                      shape: BoxShape.circle,
+                      image: DecorationImage(
+                        fit: BoxFit.contain,
+                        image: MemoryImage(
+                          File(dartboardData.imagePath).readAsBytesSync(),
+                        ),
+                      ),
+                    ),
+                  ),
+                ),
+                Center(
+                  child: Column(
+                    children: [
+                      Text(dartboardData.fullName, style: dartboardData.headerTextStyle),
+                      Text(dartboardData.phoneNumber, style: dartboardData.headerTextStyle),
+                      Text(dartboardData.email, style: dartboardData.headerTextStyle),
+                    ],
+                  ),
+                ),
+              ],
+            ),
+          ),
+          Container(height: 20),
+          ...groupedExperienceList,
+        ],
+      );
+    },
+  );
+}
diff --git a/lib/dartboard_widgets.dart b/lib/dartboard_widgets.dart
new file mode 100644
index 0000000..da5df3e
--- /dev/null
+++ b/lib/dartboard_widgets.dart
@@ -0,0 +1,123 @@
+import 'package:dartboard_resume/dartboard_parser.dart';
+import 'package:pdf/pdf.dart';
+import 'package:pdf/widgets.dart';
+
+class DartboardFooter extends StatelessWidget {
+  DartboardFooter({required this.dartboardData, required this.renderNs});
+  final int renderNs;
+  final DartboardData dartboardData;
+
+  @override
+  Widget build(Context context) {
+    final double renderTimeMs = renderNs.toDouble() / 1000.0;
+    return Row(
+      mainAxisAlignment: MainAxisAlignment.spaceAround,
+      children: [
+        Text(
+          'This resume was generated with DartBoard Resume',
+          style: dartboardData.defaultTextStyle.copyWith(fontSize: 10),
+        ),
+        Text(
+          'Rendered in ${renderTimeMs.toStringAsFixed(2)}ms',
+          style: dartboardData.defaultTextStyle.copyWith(fontSize: 10),
+        ),
+      ],
+    );
+  }
+}
+
+class DartboardExperienceEntry extends StatelessWidget {
+  DartboardExperienceEntry({required this.dartboardData, required this.exp});
+  final DartboardData dartboardData;
+  final DartboardExperience exp;
+
+  @override
+  Widget build(Context context) {
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Row(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          mainAxisAlignment: MainAxisAlignment.spaceBetween,
+          children: [
+            Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(exp.title, style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.3))),
+                Container(height: 1, width: 60, color: const PdfColorGrey(0.75)),
+              ],
+            ),
+            Column(
+              crossAxisAlignment: CrossAxisAlignment.end,
+              children: [
+                Text(
+                  exp.range.toString(),
+                  style: dartboardData.defaultTextStyle.apply(color: const PdfColorGrey(0.42)),
+                ),
+                if (exp.location != null)
+                  Text(
+                    exp.location!,
+                    style: dartboardData.defaultTextStyle.apply(color: const PdfColorGrey(0.42)),
+                  ),
+              ],
+            ),
+          ],
+        ),
+        SizedBox(height: 6),
+        ...exp.attributes.map(
+          (a) => Padding(
+            child: DartboardTextWithLink(
+              stringSections: a.toTextLinkList(),
+              bulletString: dartboardData.dartboardTheme.bulletPoint,
+              style: dartboardData.defaultTextStyle.apply(
+                color: const PdfColorGrey(0.55),
+              ),
+            ),
+            padding: const EdgeInsets.only(bottom: 4.0),
+          ),
+        ),
+        SizedBox(height: 20),
+      ],
+    );
+  }
+}
+
+// TODO this lays out long text lines on a newline after a uilink rather than soft wrapping it.
+class DartboardTextWithLink extends StatelessWidget {
+  DartboardTextWithLink({this.bulletString, required this.stringSections, this.style});
+
+  final String? bulletString;
+  final List<DartboardTextLinkData> stringSections;
+  final TextStyle? style;
+
+  @override
+  Widget build(Context context) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Text(bulletString == null ? '' : '$bulletString ', style: style),
+        SizedBox(
+          width: DartboardTheme.width - DartboardTheme.margin * 2 - 20.0,
+          child: Wrap(
+            children: [
+              ...stringSections.map((s) {
+                switch (s.type) {
+                  case DartboardTextType.normal:
+                    return Text(s.text, style: style);
+                  case DartboardTextType.linkText:
+                    return UrlLink(
+                      destination: s.url!,
+                      child: Text(
+                        s.text,
+                        style: style?.copyWith(color: PdfColors.blue),
+                      ),
+                    );
+                }
+              }),
+            ],
+          ),
+        ),
+      ],
+    );
+  }
+}
diff --git a/lib/render.dart b/lib/render.dart
new file mode 100644
index 0000000..ec0f188
--- /dev/null
+++ b/lib/render.dart
@@ -0,0 +1,118 @@
+import 'dart:io';
+
+import 'package:dartboard_resume/dartboard_parser.dart';
+import 'package:dartboard_resume/dartboard_widgets.dart';
+import 'package:pdf/pdf.dart';
+import 'package:pdf/widgets.dart';
+import 'package:toml/toml.dart';
+
+int? lastDartboardHash;
+
+Future<void> renderPdf(String tomlFilePath, {bool force = false}) async {
+  try {
+    final start = DateTime.now().microsecondsSinceEpoch;
+    final dartboardData = DartboardData.fromToml(tomlFilePath);
+    if (lastDartboardHash == dartboardData.hashCode && !force) {
+      return;
+    }
+    lastDartboardHash = dartboardData.hashCode;
+    stdout.writeln("Detected change:\nRendering with new dartboard data: $lastDartboardHash");
+    final pdfFuture = Document()..addPage(_generatePdfPage(dartboardData: dartboardData, renderNs: 0));
+    await pdfFuture.save();
+    final renderNs = DateTime.now().microsecondsSinceEpoch - start;
+    final pdf = Document();
+    pdf.addPage(_generatePdfPage(dartboardData: dartboardData, renderNs: renderNs));
+
+    final file = File("example.pdf");
+    final bytes = await pdf.save();
+
+    stdout.writeln('New pdf file saved.');
+    file.writeAsBytesSync(bytes);
+    stdout.writeln('Reloading llpp...');
+    Process.runSync('pkill', ['-HUP', 'llpp']);
+  } catch (e) {
+    stderr.writeln('Encountered error: $e');
+    try {
+      stderr.writeln('Current toml map:\n${TomlDocument.loadSync(tomlFilePath).toMap()}');
+    } catch (_) {
+      stderr.writeln('Cannot display current toml map');
+    }
+  }
+}
+
+Page _generatePdfPage({required DartboardData dartboardData, required int renderNs}) {
+  final List<Widget> groupedExperienceList = dartboardData.groupedExperiences.entries.map<Widget>(
+    (entry) {
+      final String subsection = entry.key;
+      final List<DartboardExperience> experiences = entry.value;
+      return Column(
+        children: [
+          Row(
+            children: [
+              Text(
+                subsection,
+                style: dartboardData.subheaderTextStyle
+                    .merge(const TextStyle(fontSize: 18))
+                    .apply(color: const PdfColorGrey(0.2)),
+              ),
+            ],
+          ),
+          Container(height: 2, width: 200, color: const PdfColorGrey(0.7)),
+          ...experiences.map(
+            (DartboardExperience exp) => DartboardExperienceEntry(dartboardData: dartboardData, exp: exp),
+          ),
+          DartboardFooter(dartboardData: dartboardData, renderNs: renderNs),
+        ],
+      );
+    },
+  ).toList();
+  return Page(
+    pageTheme: const PageTheme(pageFormat: PdfPageFormat.standard),
+    build: (Context context) {
+      return
+          // FullPage(
+          // ignoreMargins: true,
+          // child:
+          Column(
+        children: [
+          SizedBox(
+            height: 120,
+            width: double.infinity,
+            child: Stack(
+              children: [
+                Positioned(
+                  left: 0,
+                  child: Container(
+                    height: 100,
+                    width: 100,
+                    decoration: BoxDecoration(
+                      shape: BoxShape.circle,
+                      image: DecorationImage(
+                        fit: BoxFit.contain,
+                        image: MemoryImage(
+                          File(dartboardData.imagePath).readAsBytesSync(),
+                        ),
+                      ),
+                    ),
+                  ),
+                ),
+                Center(
+                  child: Column(
+                    children: [
+                      Text(dartboardData.fullName, style: dartboardData.headerTextStyle),
+                      Text(dartboardData.phoneNumber, style: dartboardData.headerTextStyle),
+                      Text(dartboardData.email, style: dartboardData.headerTextStyle),
+                    ],
+                  ),
+                ),
+              ],
+            ),
+          ),
+          Container(height: 20),
+          ...groupedExperienceList,
+        ],
+        // ),
+      );
+    },
+  );
+}
diff --git a/lib/utils.dart b/lib/utils.dart
new file mode 100644
index 0000000..e2ef47c
--- /dev/null
+++ b/lib/utils.dart
@@ -0,0 +1,5 @@
+extension StringUtils on String {
+  String capitalize() {
+    return substring(0, 1).toUpperCase() + substring(1);
+  }
+}
diff --git a/nerd.ttf b/nerd.ttf
new file mode 100644
index 0000000..2ff62ee
Binary files /dev/null and b/nerd.ttf differ
diff --git a/pubspec.lock b/pubspec.lock
new file mode 100644
index 0000000..c79574f
--- /dev/null
+++ b/pubspec.lock
@@ -0,0 +1,530 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  _fe_analyzer_shared:
+    dependency: transitive
+    description:
+      name: _fe_analyzer_shared
+      sha256: "5aaf60d96c4cd00fe7f21594b5ad6a1b699c80a27420f8a837f4d68473ef09e3"
+      url: "https://pub.dev"
+    source: hosted
+    version: "68.0.0"
+  _macros:
+    dependency: transitive
+    description: dart
+    source: sdk
+    version: "0.1.0"
+  analyzer:
+    dependency: transitive
+    description:
+      name: analyzer
+      sha256: "21f1d3720fd1c70316399d5e2bccaebb415c434592d778cce8acb967b8578808"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.5.0"
+  archive:
+    dependency: transitive
+    description:
+      name: archive
+      sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.6.1"
+  args:
+    dependency: transitive
+    description:
+      name: args
+      sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.5.0"
+  async:
+    dependency: transitive
+    description:
+      name: async
+      sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.11.0"
+  barcode:
+    dependency: transitive
+    description:
+      name: barcode
+      sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.8"
+  bidi:
+    dependency: transitive
+    description:
+      name: bidi
+      sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.12"
+  boolean_selector:
+    dependency: transitive
+    description:
+      name: boolean_selector
+      sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.1"
+  clock:
+    dependency: transitive
+    description:
+      name: clock
+      sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.1"
+  collection:
+    dependency: transitive
+    description:
+      name: collection
+      sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.19.0"
+  convert:
+    dependency: transitive
+    description:
+      name: convert
+      sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.1"
+  coverage:
+    dependency: transitive
+    description:
+      name: coverage
+      sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.9.2"
+  crypto:
+    dependency: transitive
+    description:
+      name: crypto
+      sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.5"
+  file:
+    dependency: transitive
+    description:
+      name: file
+      sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.0.0"
+  frontend_server_client:
+    dependency: transitive
+    description:
+      name: frontend_server_client
+      sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.0"
+  glob:
+    dependency: transitive
+    description:
+      name: glob
+      sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  hotreloader:
+    dependency: "direct dev"
+    description:
+      name: hotreloader
+      sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.2.0"
+  http_multi_server:
+    dependency: transitive
+    description:
+      name: http_multi_server
+      sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.2.1"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "40f592dd352890c3b60fec1b68e786cefb9603e05ff303dbc4dda49b304ecdf4"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.1.0"
+  image:
+    dependency: transitive
+    description:
+      name: image
+      sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.2.0"
+  intl:
+    dependency: "direct main"
+    description:
+      name: intl
+      sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.19.0"
+  io:
+    dependency: transitive
+    description:
+      name: io
+      sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.4"
+  js:
+    dependency: transitive
+    description:
+      name: js
+      sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.1"
+  lint:
+    dependency: "direct main"
+    description:
+      name: lint
+      sha256: d758a5211fce7fd3f5e316f804daefecdc34c7e53559716125e6da7388ae8565
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.0"
+  lints:
+    dependency: "direct dev"
+    description:
+      name: lints
+      sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.0"
+  logging:
+    dependency: "direct main"
+    description:
+      name: logging
+      sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.0"
+  macros:
+    dependency: transitive
+    description:
+      name: macros
+      sha256: "12e8a9842b5a7390de7a781ec63d793527582398d16ea26c60fed58833c9ae79"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.0-main.0"
+  matcher:
+    dependency: transitive
+    description:
+      name: matcher
+      sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.12.16+1"
+  meta:
+    dependency: transitive
+    description:
+      name: meta
+      sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.15.0"
+  mime:
+    dependency: transitive
+    description:
+      name: mime
+      sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.6"
+  node_preamble:
+    dependency: transitive
+    description:
+      name: node_preamble
+      sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.2"
+  package_config:
+    dependency: transitive
+    description:
+      name: package_config
+      sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.0"
+  path:
+    dependency: transitive
+    description:
+      name: path
+      sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.9.0"
+  path_parsing:
+    dependency: transitive
+    description:
+      name: path_parsing
+      sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.1"
+  pdf:
+    dependency: "direct main"
+    description:
+      name: pdf
+      sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.11.1"
+  petitparser:
+    dependency: transitive
+    description:
+      name: petitparser
+      sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.2"
+  pool:
+    dependency: transitive
+    description:
+      name: pool
+      sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.5.1"
+  pub_semver:
+    dependency: transitive
+    description:
+      name: pub_semver
+      sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.4"
+  qr:
+    dependency: transitive
+    description:
+      name: qr
+      sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.2"
+  shelf:
+    dependency: transitive
+    description:
+      name: shelf
+      sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.4.2"
+  shelf_packages_handler:
+    dependency: transitive
+    description:
+      name: shelf_packages_handler
+      sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.2"
+  shelf_static:
+    dependency: transitive
+    description:
+      name: shelf_static
+      sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.2"
+  shelf_web_socket:
+    dependency: transitive
+    description:
+      name: shelf_web_socket
+      sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.0"
+  source_map_stack_trace:
+    dependency: transitive
+    description:
+      name: source_map_stack_trace
+      sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  source_maps:
+    dependency: transitive
+    description:
+      name: source_maps
+      sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.10.12"
+  source_span:
+    dependency: transitive
+    description:
+      name: source_span
+      sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.10.0"
+  stack_trace:
+    dependency: transitive
+    description:
+      name: stack_trace
+      sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.11.1"
+  stream_channel:
+    dependency: transitive
+    description:
+      name: stream_channel
+      sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  stream_transform:
+    dependency: transitive
+    description:
+      name: stream_transform
+      sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.0"
+  string_scanner:
+    dependency: transitive
+    description:
+      name: string_scanner
+      sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.0"
+  term_glyph:
+    dependency: transitive
+    description:
+      name: term_glyph
+      sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  test:
+    dependency: "direct dev"
+    description:
+      name: test
+      sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.25.8"
+  test_api:
+    dependency: transitive
+    description:
+      name: test_api
+      sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.3"
+  test_core:
+    dependency: transitive
+    description:
+      name: test_core
+      sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.6.5"
+  toml:
+    dependency: "direct main"
+    description:
+      name: toml
+      sha256: d968d149c8bd06dc14e09ea3a140f90a3f2ba71949e7a91df4a46f3107400e71
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.16.0"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.2"
+  vector_math:
+    dependency: transitive
+    description:
+      name: vector_math
+      sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.4"
+  vm_service:
+    dependency: transitive
+    description:
+      name: vm_service
+      sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "14.2.5"
+  watcher:
+    dependency: transitive
+    description:
+      name: watcher
+      sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  web:
+    dependency: transitive
+    description:
+      name: web
+      sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.0"
+  web_socket:
+    dependency: transitive
+    description:
+      name: web_socket
+      sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.6"
+  web_socket_channel:
+    dependency: transitive
+    description:
+      name: web_socket_channel
+      sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.1"
+  webkit_inspection_protocol:
+    dependency: transitive
+    description:
+      name: webkit_inspection_protocol
+      sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  xml:
+    dependency: transitive
+    description:
+      name: xml
+      sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.5.0"
+  yaml:
+    dependency: transitive
+    description:
+      name: yaml
+      sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.2"
+sdks:
+  dart: ">=3.4.4 <4.0.0"
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..81adf0b
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,22 @@
+name: dartboard_resume
+description: A sample command-line application.
+version: 1.0.0
+# repository: https://github.com/my_org/my_repo
+
+environment:
+  sdk: ^3.4.4
+
+# Add regular dependencies here.
+dependencies:
+  intl: ^0.19.0
+  lint: ^2.3.0
+  logging: ^1.2.0
+  pdf: ^3.11.1
+  toml: ^0.16.0
+  # path: ^1.8.0
+
+dev_dependencies:
+  lints: ^3.0.0
+  test: ^1.24.0
+  hotreloader: ^4.2.0
+
diff --git a/test/dartboard_resume_test.dart b/test/dartboard_resume_test.dart
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/test/dartboard_resume_test.dart
@@ -0,0 +1 @@
+