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 units, required List 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 = {}; 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 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 _objects = []; final List _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 positions, required List roots, required List 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 _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 = []; 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); } }