uto_manager/lib/infrastructure/codecs/pdf_chart_exporter.dart

441 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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; // 0.5 inch in points
static const double _nodeW = 110.0;
static const double _nodeH = 36.0;
static const double _levelGap = 60.0; // vertical gap between echelon levels
static const double _siblingGap = 8.0; // 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();
final treeService = const UnitTreeService();
final roots = treeService.buildTree(units, slots);
// Compute layout positions for all nodes.
final positions = <String, _NodePos>{};
double 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);
builder.addCoverPage(
unitDesignation: unitDesignation,
exportDate: date,
classification: _classificationBanner,
);
builder.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.
double 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();
stream.writeln('BT');
// Classification banner — top
stream.writeln('/F1 10 Tf');
stream.writeln('${cx - 40} ${h - 20} Td');
stream.writeln('($classification) Tj');
// Title
stream.writeln('/F1 24 Tf');
stream.writeln('${cx - 80} ${h / 2 + 40} Td');
stream.writeln('(UTO Manager — Organizational Chart) Tj');
// Unit designation
stream.writeln('/F1 18 Tf');
stream.writeln('${cx - 60} ${h / 2} Td');
stream.writeln('($unitDesignation) Tj');
// Export date
stream.writeln('/F1 12 Tf');
stream.writeln('${cx - 40} ${h / 2 - 30} Td');
stream.writeln('(Exported: $dateStr) Tj');
// Classification banner — bottom
stream.writeln('/F1 10 Tf');
stream.writeln('${cx - 40} 10 Td');
stream.writeln('($classification) Tj');
stream.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
stream.writeln('BT /F1 10 Tf ${cx - 40} ${h - 20} Td ($classification) Tj ET');
stream.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
stream.writeln('0.5 w'); // line width
stream.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);
stream.writeln('${rgb[0]} ${rgb[1]} ${rgb[2]} rg'); // fill
stream.writeln('0.2 0.2 0.4 RG'); // stroke
stream.writeln('0.8 w');
stream.writeln(
'$x $y ${PdfChartExporter._nodeW} ${PdfChartExporter._nodeH} re B',
);
// Unit name label (truncated to fit box).
final label = _truncate(unit.name, 18);
final echelonLabel = unit.echelon.displayName;
stream.writeln('1 1 1 rg'); // white text
stream.writeln(
'BT /F1 7 Tf ${x + 4} ${y + PdfChartExporter._nodeH - 12} Td ($label) Tj ET',
);
stream.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();
buf.writeln('%PDF-1.4');
buf.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');
buf.writeln('0 ${totalObjects + 1}');
buf.writeln('0000000000 65535 f ');
for (final offset in offsets) {
buf.writeln(offset.toString().padLeft(10, '0') + ' 00000 n ');
}
// Trailer.
buf.writeln('trailer');
buf.writeln('<< /Size ${totalObjects + 1} /Root $catalogObjNum 0 R >>');
buf.writeln('startxref');
buf.writeln(xrefOffset);
buf.writeln('%%EOF');
return Uint8List.fromList(buf.toString().codeUnits);
}
}