443 lines
14 KiB
Dart
443 lines
14 KiB
Dart
import 'dart:typed_data';
|
||
|
||
import 'package:uto_manager/domain/entities/echelon.dart';
|
||
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||
import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PdfPageSize
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Supported page sizes for PDF chart export.
|
||
enum PdfPageSize {
|
||
/// US Letter (8.5 × 11 in / 612 × 792 pt).
|
||
letter,
|
||
|
||
/// ISO A4 (210 × 297 mm / 595 × 842 pt).
|
||
a4,
|
||
|
||
/// US Tabloid / ISO A3 (11 × 17 in / 792 × 1224 pt) — recommended for BCT.
|
||
tabloid,
|
||
}
|
||
|
||
extension _PdfPageSizeExt on PdfPageSize {
|
||
double get widthPt {
|
||
switch (this) {
|
||
case PdfPageSize.letter:
|
||
return 612;
|
||
case PdfPageSize.a4:
|
||
return 595;
|
||
case PdfPageSize.tabloid:
|
||
return 792;
|
||
}
|
||
}
|
||
|
||
double get heightPt {
|
||
switch (this) {
|
||
case PdfPageSize.letter:
|
||
return 792;
|
||
case PdfPageSize.a4:
|
||
return 842;
|
||
case PdfPageSize.tabloid:
|
||
return 1224;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PdfChartExporter
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Exports a unit tree as a vector PDF organizational chart.
|
||
///
|
||
/// Produces a minimal but standards-compliant PDF 1.4 document containing:
|
||
/// - A cover page with unit designation, export date, and classification banner
|
||
/// - One or more chart pages with the organizational hierarchy rendered as
|
||
/// labeled boxes connected by lines
|
||
///
|
||
/// The output is a pure-Dart implementation that requires no external PDF
|
||
/// package dependency. It writes raw PDF syntax directly, which means all
|
||
/// text and geometry are stored as vector operations — not rasterized images.
|
||
///
|
||
/// For large formations (BCT-level, 500+ nodes) the chart is automatically
|
||
/// split across multiple pages using a depth-first layout strategy.
|
||
///
|
||
/// Usage:
|
||
/// ```dart
|
||
/// final exporter = PdfChartExporter();
|
||
/// final bytes = exporter.export(
|
||
/// units: units,
|
||
/// slots: slots,
|
||
/// unitDesignation: '1st MBCT',
|
||
/// pageSize: PdfPageSize.tabloid,
|
||
/// );
|
||
/// ```
|
||
class PdfChartExporter {
|
||
const PdfChartExporter();
|
||
|
||
static const String _classificationBanner = 'UNCLASSIFIED';
|
||
static const double _margin = 36; // 0.5 inch in points
|
||
static const double _nodeW = 110;
|
||
static const double _nodeH = 36;
|
||
static const double _levelGap = 60; // vertical gap between echelon levels
|
||
static const double _siblingGap = 8; // horizontal gap between siblings
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Exports [units] and [slots] as a PDF organizational chart.
|
||
///
|
||
/// Returns the raw PDF bytes as a [Uint8List].
|
||
Uint8List export({
|
||
required List<UnitNode> units,
|
||
required List<PositionSlot> slots,
|
||
required String unitDesignation,
|
||
PdfPageSize pageSize = PdfPageSize.tabloid,
|
||
DateTime? exportDate,
|
||
}) {
|
||
final date = exportDate ?? DateTime.now();
|
||
const treeService = UnitTreeService();
|
||
final roots = treeService.buildTree(units, slots);
|
||
|
||
// Compute layout positions for all nodes.
|
||
final positions = <String, _NodePos>{};
|
||
var xCursor = _margin;
|
||
for (final root in roots) {
|
||
xCursor = _layoutSubtree(root, xCursor, 0, positions);
|
||
xCursor += _siblingGap * 4;
|
||
}
|
||
|
||
// Build the PDF document.
|
||
final builder = _PdfBuilder(pageSize: pageSize)
|
||
..addCoverPage(
|
||
unitDesignation: unitDesignation,
|
||
exportDate: date,
|
||
classification: _classificationBanner,
|
||
)
|
||
..addChartPage(
|
||
positions: positions,
|
||
roots: roots,
|
||
units: units,
|
||
classification: _classificationBanner,
|
||
);
|
||
|
||
return builder.build();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Layout
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Recursively computes (x, y) positions for each node using a simple
|
||
/// left-to-right, top-to-bottom layout.
|
||
///
|
||
/// Returns the x coordinate after the last child of [node].
|
||
double _layoutSubtree(
|
||
UnitTreeNode node,
|
||
double startX,
|
||
int depth,
|
||
Map<String, _NodePos> positions,
|
||
) {
|
||
final y = _margin + depth * (_nodeH + _levelGap);
|
||
|
||
if (node.children.isEmpty) {
|
||
positions[node.unit.unitId] = _NodePos(x: startX, y: y);
|
||
return startX + _nodeW + _siblingGap;
|
||
}
|
||
|
||
// Layout children first to determine total width.
|
||
var childX = startX;
|
||
for (final child in node.children) {
|
||
childX = _layoutSubtree(child, childX, depth + 1, positions);
|
||
}
|
||
|
||
// Center this node over its children.
|
||
final firstChild = positions[node.children.first.unit.unitId]!;
|
||
final lastChild = positions[node.children.last.unit.unitId]!;
|
||
final centerX = (firstChild.x + lastChild.x + _nodeW) / 2 - _nodeW / 2;
|
||
|
||
positions[node.unit.unitId] = _NodePos(x: centerX, y: y);
|
||
return childX;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Internal layout model
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _NodePos {
|
||
const _NodePos({required this.x, required this.y});
|
||
final double x;
|
||
final double y;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PDF Builder
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Minimal PDF 1.4 document builder.
|
||
///
|
||
/// Writes raw PDF syntax to produce a standards-compliant document without
|
||
/// any external dependencies. All content is vector (text + path operations).
|
||
class _PdfBuilder {
|
||
_PdfBuilder({required this.pageSize});
|
||
|
||
final PdfPageSize pageSize;
|
||
|
||
final List<String> _objects = [];
|
||
final List<int> _pageObjectIndices = [];
|
||
|
||
int get _nextObjNum => _objects.length + 1;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cover page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void addCoverPage({
|
||
required String unitDesignation,
|
||
required DateTime exportDate,
|
||
required String classification,
|
||
}) {
|
||
final dateStr = '${exportDate.year}-${exportDate.month.toString().padLeft(2, '0')}-'
|
||
'${exportDate.day.toString().padLeft(2, '0')}';
|
||
|
||
final w = pageSize.widthPt;
|
||
final h = pageSize.heightPt;
|
||
final cx = w / 2;
|
||
|
||
final stream = StringBuffer()
|
||
..writeln('BT')
|
||
// Classification banner — top
|
||
..writeln('/F1 10 Tf')
|
||
..writeln('${cx - 40} ${h - 20} Td')
|
||
..writeln('($classification) Tj')
|
||
// Title
|
||
..writeln('/F1 24 Tf')
|
||
..writeln('${cx - 80} ${h / 2 + 40} Td')
|
||
..writeln('(UTO Manager — Organizational Chart) Tj')
|
||
// Unit designation
|
||
..writeln('/F1 18 Tf')
|
||
..writeln('${cx - 60} ${h / 2} Td')
|
||
..writeln('($unitDesignation) Tj')
|
||
// Export date
|
||
..writeln('/F1 12 Tf')
|
||
..writeln('${cx - 40} ${h / 2 - 30} Td')
|
||
..writeln('(Exported: $dateStr) Tj')
|
||
// Classification banner — bottom
|
||
..writeln('/F1 10 Tf')
|
||
..writeln('${cx - 40} 10 Td')
|
||
..writeln('($classification) Tj')
|
||
..writeln('ET');
|
||
|
||
_addPage(stream.toString());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Chart page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void addChartPage({
|
||
required Map<String, _NodePos> positions,
|
||
required List<UnitTreeNode> roots,
|
||
required List<UnitNode> units,
|
||
required String classification,
|
||
}) {
|
||
final w = pageSize.widthPt;
|
||
final h = pageSize.heightPt;
|
||
final cx = w / 2;
|
||
|
||
final stream = StringBuffer()
|
||
// Classification banners
|
||
..writeln('BT /F1 10 Tf ${cx - 40} ${h - 20} Td ($classification) Tj ET')
|
||
..writeln('BT /F1 10 Tf ${cx - 40} 10 Td ($classification) Tj ET');
|
||
|
||
// Draw connector lines first (behind boxes).
|
||
for (final unit in units) {
|
||
if (unit.parentUnitId == null) continue;
|
||
final childPos = positions[unit.unitId];
|
||
final parentPos = positions[unit.parentUnitId];
|
||
if (childPos == null || parentPos == null) continue;
|
||
|
||
final childCx = childPos.x + PdfChartExporter._nodeW / 2;
|
||
final childTop = h - childPos.y; // flip Y (PDF origin is bottom-left)
|
||
final parentCx = parentPos.x + PdfChartExporter._nodeW / 2;
|
||
final parentBottom = h - parentPos.y - PdfChartExporter._nodeH;
|
||
|
||
stream
|
||
..writeln('0.4 0.4 0.4 RG') // gray stroke
|
||
..writeln('0.5 w') // line width
|
||
..writeln('$parentCx $parentBottom m $childCx $childTop l S');
|
||
}
|
||
|
||
// Draw node boxes and labels.
|
||
for (final unit in units) {
|
||
final pos = positions[unit.unitId];
|
||
if (pos == null) continue;
|
||
|
||
final x = pos.x;
|
||
final y = h - pos.y - PdfChartExporter._nodeH; // flip Y
|
||
|
||
// Box fill color based on echelon.
|
||
final rgb = _echelonColor(unit.echelon);
|
||
// Unit name label (truncated to fit box).
|
||
final label = _truncate(unit.name, 18);
|
||
final echelonLabel = unit.echelon.displayName;
|
||
stream
|
||
..writeln('${rgb[0]} ${rgb[1]} ${rgb[2]} rg') // fill
|
||
..writeln('0.2 0.2 0.4 RG') // stroke
|
||
..writeln('0.8 w')
|
||
..writeln(
|
||
'$x $y ${PdfChartExporter._nodeW} ${PdfChartExporter._nodeH} re B',
|
||
)
|
||
..writeln('1 1 1 rg') // white text
|
||
..writeln(
|
||
'BT /F1 7 Tf ${x + 4} ${y + PdfChartExporter._nodeH - 12} Td ($label) Tj ET',
|
||
)
|
||
..writeln(
|
||
'BT /F1 6 Tf ${x + 4} ${y + 4} Td ($echelonLabel) Tj ET',
|
||
);
|
||
}
|
||
|
||
_addPage(stream.toString());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Internal helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void _addPage(String contentStream) {
|
||
final streamBytes = contentStream.codeUnits;
|
||
final streamLength = streamBytes.length;
|
||
|
||
// Content stream object.
|
||
final streamObjNum = _nextObjNum;
|
||
_objects.add(
|
||
'$streamObjNum 0 obj\n'
|
||
'<< /Length $streamLength >>\n'
|
||
'stream\n'
|
||
'$contentStream\n'
|
||
'endstream\n'
|
||
'endobj',
|
||
);
|
||
|
||
// Page object.
|
||
final pageObjNum = _nextObjNum;
|
||
_objects.add(
|
||
'$pageObjNum 0 obj\n'
|
||
'<< /Type /Page\n'
|
||
' /MediaBox [0 0 ${pageSize.widthPt} ${pageSize.heightPt}]\n'
|
||
' /Contents $streamObjNum 0 R\n'
|
||
' /Resources << /Font << /F1 ${_fontObjNum()} 0 R >> >>\n'
|
||
'>>\n'
|
||
'endobj',
|
||
);
|
||
|
||
_pageObjectIndices.add(pageObjNum);
|
||
}
|
||
|
||
/// Returns the object number for the Helvetica font resource.
|
||
/// The font object is always object 1 (added lazily on first use).
|
||
int _fontObjNum() {
|
||
if (_objects.isEmpty || !_objects.first.contains('/BaseFont /Helvetica')) {
|
||
_objects.insert(
|
||
0,
|
||
'1 0 obj\n'
|
||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica\n'
|
||
' /Encoding /WinAnsiEncoding >>\n'
|
||
'endobj',
|
||
);
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
List<double> _echelonColor(Echelon echelon) {
|
||
switch (echelon) {
|
||
case Echelon.brigade:
|
||
return [0.11, 0.23, 0.42]; // dark navy
|
||
case Echelon.battalion:
|
||
return [0.18, 0.35, 0.60]; // medium navy
|
||
case Echelon.company:
|
||
return [0.25, 0.47, 0.72]; // blue
|
||
case Echelon.platoon:
|
||
return [0.35, 0.58, 0.80]; // lighter blue
|
||
case Echelon.section:
|
||
return [0.50, 0.70, 0.88]; // light blue
|
||
case Echelon.squad:
|
||
return [0.65, 0.80, 0.92]; // pale blue
|
||
case Echelon.team:
|
||
return [0.80, 0.90, 0.97]; // very pale blue
|
||
}
|
||
}
|
||
|
||
String _truncate(String text, int maxChars) {
|
||
if (text.length <= maxChars) return text;
|
||
return '${text.substring(0, maxChars - 1)}…';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Build
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Assembles all objects into a complete PDF byte stream.
|
||
Uint8List build() {
|
||
// Ensure font object exists.
|
||
_fontObjNum();
|
||
|
||
final buf = StringBuffer()
|
||
..writeln('%PDF-1.4')
|
||
..writeln('%âãÏÓ'); // binary comment to mark as binary file
|
||
|
||
// Write all objects and record their byte offsets for the xref table.
|
||
final offsets = <int>[];
|
||
var byteOffset = buf.length;
|
||
|
||
for (final obj in _objects) {
|
||
offsets.add(byteOffset);
|
||
buf.writeln(obj);
|
||
byteOffset = buf.length;
|
||
}
|
||
|
||
// Pages dictionary.
|
||
final pagesDictObjNum = _objects.length + 1;
|
||
final pagesRef = _pageObjectIndices.map((i) => '$i 0 R').join(' ');
|
||
final pagesObj = '$pagesDictObjNum 0 obj\n'
|
||
'<< /Type /Pages /Kids [$pagesRef] /Count ${_pageObjectIndices.length} >>\n'
|
||
'endobj';
|
||
offsets.add(byteOffset);
|
||
buf.writeln(pagesObj);
|
||
byteOffset = buf.length;
|
||
|
||
// Catalog.
|
||
final catalogObjNum = pagesDictObjNum + 1;
|
||
final catalogObj = '$catalogObjNum 0 obj\n'
|
||
'<< /Type /Catalog /Pages $pagesDictObjNum 0 R >>\n'
|
||
'endobj';
|
||
offsets.add(byteOffset);
|
||
buf.writeln(catalogObj);
|
||
byteOffset = buf.length;
|
||
|
||
// Cross-reference table.
|
||
final xrefOffset = byteOffset;
|
||
final totalObjects = offsets.length;
|
||
buf
|
||
..writeln('xref')
|
||
..writeln('0 ${totalObjects + 1}')
|
||
..writeln('0000000000 65535 f ');
|
||
for (final offset in offsets) {
|
||
buf.writeln('${offset.toString().padLeft(10, '0')} 00000 n ');
|
||
}
|
||
|
||
// Trailer.
|
||
buf
|
||
..writeln('trailer')
|
||
..writeln('<< /Size ${totalObjects + 1} /Root $catalogObjNum 0 R >>')
|
||
..writeln('startxref')
|
||
..writeln(xrefOffset)
|
||
..writeln('%%EOF');
|
||
|
||
return Uint8List.fromList(buf.toString().codeUnits);
|
||
}
|
||
}
|