-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathflat-generator.ts
More file actions
1620 lines (1375 loc) · 61.2 KB
/
Copy pathflat-generator.ts
File metadata and controls
1620 lines (1375 loc) · 61.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Flat LaTeX Generator for Pandoc DOCX Conversion
*
* Produces a single, self-contained .tex file using ONLY standard LaTeX
* constructs that pandoc understands. No custom macros, no \input{} calls.
*
* Key pandoc-compatible patterns used throughout:
* - tabularX{XcX} for centered content (pandoc ignores \begin{center})
* - tabularX{Xr} for right-aligned content (pandoc ignores \begin{flushright})
* - tabular for address/ref/encl blocks with proper column alignment
* - \mbox{label} to protect numbered labels from pandoc's list detection
* - Blank lines between paragraphs (pandoc ignores \\[Xpt])
* - \vspace{Xpt} for vertical spacing (12pt/6pt/24pt/48pt)
* - \newline for line breaks within a paragraph
*/
import type { DocumentData, Reference, Enclosure, Paragraph, CopyTo, Distribution, DocTypeConfig } from '@/types/document';
import { DOC_TYPE_CONFIG } from '@/types/document';
import { LAYOUT, TEXT_WIDTH_IN } from '@/services/docx/layout-config';
import { enclosureStartNumber, pageStartNumber } from '@/lib/endorsement';
import {
resolveAppendedEndorsement,
appendedEndorsementSigner,
} from '@/lib/appendedEndorsement';
import { splitAddressForLetterhead } from '@/lib/unitAddress';
import { formatViaLines } from '@/lib/viaLines';
import { deriveOverallClassLevel } from '@/lib/overallClassification';
interface DocumentStore {
docType: string;
formData: Partial<DocumentData>;
references: Reference[];
enclosures: Enclosure[];
paragraphs: Paragraph[];
copyTos: CopyTo[];
distributions: Distribution[];
}
// --- Utility functions ---
function escapeFlat(str: string | undefined | null): string {
if (!str) return '';
// Note: $ uses {\char36} instead of \$ to avoid TS1 font encoding requirement
// in SwiftLaTeX. Pandoc also handles {\char36} correctly for DOCX.
// ORDER MATTERS: Use placeholders for replacements that introduce { }
// so they don't get re-escaped by the { } escaping step.
// codeql[js/incomplete-sanitization]: false positive — sentinel pattern
// (first replace) escapes all `\` from input before subsequent replaces add their own.
return str
.replace(/\\/g, 'ZZZTEXTBACKSLASHZZZ')
.replace(/&/g, '\\&')
.replace(/%/g, '\\%')
.replace(/#/g, '\\#')
.replace(/_/g, '\\_')
.replace(/\$/g, 'ZZZDOLLARZZZ')
.replace(/~/g, 'ZZZTILDEZZZ')
.replace(/\^/g, 'ZZZCARETZZZ')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/ZZZTEXTBACKSLASHZZZ/g, '\\textbackslash{}')
.replace(/ZZZDOLLARZZZ/g, '{\\char36}')
.replace(/ZZZTILDEZZZ/g, '\\textasciitilde{}')
.replace(/ZZZCARETZZZ/g, '\\textasciicircum{}');
}
/** Escape for use inside tabular cells. User CONTENT ampersands must be
* escaped (\&) — the old "& is the column separator" rationale applied to
* the table SYNTAX our code emits, not to cell text. Unescaped, a unit name
* like "H&S Battalion" became a phantom alignment tab that corrupted the
* DOCX table (the PDF path always escaped it). */
function escapeTabular(str: string | undefined | null): string {
if (!str) return '';
// ORDER MATTERS: Use placeholders for replacements that introduce { }
// so they don't get re-escaped by the { } escaping step.
// codeql[js/incomplete-sanitization]: false positive — sentinel pattern
// (first replace) escapes all `\` from input before subsequent replaces add their own.
return str
.replace(/\\/g, 'ZZZTEXTBACKSLASHZZZ')
.replace(/&/g, '\\&')
.replace(/%/g, '\\%')
.replace(/#/g, '\\#')
.replace(/_/g, '\\_')
.replace(/\$/g, 'ZZZDOLLARZZZ')
.replace(/~/g, 'ZZZTILDEZZZ')
.replace(/\^/g, 'ZZZCARETZZZ')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/ZZZTEXTBACKSLASHZZZ/g, '\\textbackslash{}')
.replace(/ZZZDOLLARZZZ/g, '{\\char36}')
.replace(/ZZZTILDEZZZ/g, '\\textasciitilde{}')
.replace(/ZZZCARETZZZ/g, '\\textasciicircum{}');
}
/** Convert rich text markers to standard LaTeX */
function convertRichText(text: string): string {
let result = text;
result = result.replace(/\*\*(.+?)\*\*/g, '\\textbf{$1}');
result = result.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '\\textit{$1}');
// [^_]+? (not .+?) — see escaper.ts and variable-chip-editor.tsx for
// the matching change. Prevents `__________` fill-in-the-blank runs
// from being partially consumed into `\uline{_}` markup. Issue #14.
result = result.replace(/__([^_]+?)__/g, '\\uline{$1}');
// Enclosure references: "Enclosure (1)", "enclosure (1)", "Encl (1)" → \enclref{1}
result = result.replace(/[Ee]nclosure\s*\((\d+)\)/g, '\\enclref{$1}');
result = result.replace(/[Ee]ncl\s*\((\d+)\)/g, '\\enclref{$1}');
// Reference cross-links: "Reference (a)", "ref (a)" → \reflink{a}
result = result.replace(/[Rr]eference\s*\(([a-zA-Z])\)/g, '\\reflink{$1}');
result = result.replace(/[Rr]ef\s*\(([a-zA-Z])\)/g, '\\reflink{$1}');
return result;
}
/** Process body text: escape LaTeX specials, handle placeholders, then convert rich text */
function processText(text: string): string {
// Extract and protect {{PLACEHOLDER}} before escaping
const placeholderMap: Record<string, string> = {};
let placeholderIndex = 0;
let result = text.replace(/\{\{([A-Za-z0-9_]+)\}\}/g, (_match, name) => {
const key = `ZZZVARPLACEHOLDER${placeholderIndex++}ZZZ`;
placeholderMap[key] = name;
return key;
});
// Escape LaTeX specials.
// codeql[js/incomplete-sanitization]: false positive — the first replace
// converts every `\` from input to `\textbackslash{}`, so subsequent replaces
// that add `\<char>` tokens don't double-escape original input backslashes.
result = result
.replace(/\\/g, '\\textbackslash{}')
.replace(/&/g, '\\&')
.replace(/%/g, '\\%')
.replace(/\$/g, '\\$')
.replace(/#/g, '\\#')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/~/g, '\\textasciitilde{}')
.replace(/\^/g, '\\textasciicircum{}');
// Convert rich text markers
result = convertRichText(result);
// Restore placeholders with highlighted LaTeX rendering
// The Lua filter converts \fcolorbox to bold {{NAME}} for DOCX
for (const [key, name] of Object.entries(placeholderMap)) {
// codeql[js/incomplete-sanitization]: false positive — `name` is captured
// from /\{\{([A-Za-z0-9_]+)\}\}/, which cannot contain `\`.
const escapedName = name.replace(/_/g, '\\_');
result = result.replace(key, `\\fcolorbox{orange}{yellow!30}{\\textsf{\\small ${escapedName}}}`);
}
return result;
}
function capitalizeWord(word: string | undefined): string {
if (!word) return '';
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
/** Strip punctuation from paragraph headers per SECNAV formatting rules.
* Dashes (-, –, —) are preserved; all other punctuation is removed. */
function stripHeaderPunctuation(text: string): string {
return text.replace(/[(),.;:!?'"/\\]/g, '').replace(/\s+/g, ' ').trim();
}
/** Underline entire header text using ulem's \uline for proper positioning. */
function underlineWords(text: string): string {
return `\\uline{${text}}`;
}
function toTitleCase(str: string): string {
const lowercaseWords = ['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'yet'];
return str.split(' ').map((word, index) => {
const lower = word.toLowerCase();
if (index === 0 || !lowercaseWords.includes(lower)) {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
return lower;
}).join(' ');
}
function abbreviateName(first: string | undefined, middle: string | undefined, last: string | undefined): string {
const parts: string[] = [];
if (first) parts.push(`${first[0].toUpperCase()}.`);
if (middle) parts.push(`${middle[0].toUpperCase()}.`);
if (last) parts.push(last.toUpperCase());
return parts.join(' ');
}
function buildFullName(first: string | undefined, middle: string | undefined, last: string | undefined): string {
return [capitalizeWord(first), capitalizeWord(middle), last?.toUpperCase() || ''].filter(Boolean).join(' ');
}
/** Generate paragraph label per SECNAV Ch 7 ¶13, Figure 7-8.
* Levels 0-3: 1./a./(1)/(a) — plain
* Levels 4-7: same pattern but underlined (spec levels 5-8) */
function getParagraphLabel(level: number, count: number): string {
const patterns = [
(n: number) => `${n}.`,
(n: number) => `${String.fromCharCode(96 + n)}.`,
(n: number) => `(${n})`,
(n: number) => `(${String.fromCharCode(96 + n)})`,
];
const label = patterns[level % 4](count);
return level >= 4 ? `\\uline{${label}}` : label;
}
function calculateLabels(paragraphs: Paragraph[]): string[] {
const labels: string[] = [];
const counters = [0, 0, 0, 0, 0, 0, 0, 0];
for (const para of paragraphs) {
for (let i = para.level + 1; i < 8; i++) counters[i] = 0;
counters[para.level]++;
labels.push(getParagraphLabel(para.level, counters[para.level]));
}
return labels;
}
function getDepartmentName(dept: string | undefined): string {
switch (dept) {
case 'usmc': return 'UNITED STATES MARINE CORPS';
case 'navy': return 'DEPARTMENT OF THE NAVY';
case 'dod': return 'DEPARTMENT OF DEFENSE';
default: return 'UNITED STATES MARINE CORPS';
}
}
// --- Section builders (pandoc-friendly) ---
function getFontPackage(fontFamily: string): string {
switch (fontFamily) {
case 'courier':
return '\\usepackage{courier}\n\\renewcommand{\\familydefault}{\\ttdefault}';
case 'times':
default:
return '\\usepackage{mathptmx}'; // Times New Roman equivalent
}
}
function buildPreamble(data: Partial<DocumentData>): string {
const fontSize = data.fontSize || '12pt';
const fontFamily = data.fontFamily || 'times';
// PMS 288 navy blue per MCO 5216.20B Section 2, para 1.a
const letterheadColor = data.letterheadColor === 'black' ? 'black' : 'navyblue';
const colorDef = letterheadColor === 'navyblue'
? '\\definecolor{navyblue}{RGB}{0,32,91}'
: ''; // black is a built-in color
return `\\documentclass[${fontSize}]{article}
\\usepackage[margin=1in]{geometry}
\\usepackage{fancyhdr}
\\usepackage{tabularx}
\\usepackage{graphicx}
\\usepackage{setspace}
\\usepackage{xcolor}
${getFontPackage(fontFamily)}
${colorDef}
\\singlespacing
\\pagestyle{fancy}
\\fancyhf{}
\\renewcommand{\\headrulewidth}{0pt}
\\setlength{\\parindent}{0pt}
\\setlength{\\parskip}{0pt}
`;
}
function buildClassificationHeaders(data: Partial<DocumentData>, paragraphs: Paragraph[]): string {
// Banner = max(document level, highest portion mark) — same chokepoint as
// the PDF path (SECNAV M-5216.5 / DoDM 5200.01 Vol 2).
const classLevel = deriveOverallClassLevel(data.classLevel, paragraphs);
if (!classLevel || classLevel === 'unclassified') return '';
let marking: string;
if (classLevel === 'cui') marking = 'CUI';
else if (classLevel === 'custom' && data.customClassification) marking = data.customClassification;
else {
const map: Record<string, string> = {
confidential: 'CONFIDENTIAL',
secret: 'SECRET',
top_secret: 'TOP SECRET',
top_secret_sci: 'TOP SECRET//SCI',
};
marking = map[classLevel] || '';
}
if (!marking) return '';
return `\\fancyhead[C]{\\textbf{${escapeFlat(marking)}}}
\\fancyfoot[C]{\\textbf{${escapeFlat(marking)}}}
`;
}
function buildPageNumbering(docType: string, data: Partial<DocumentData>): string {
const style = data.pageNumbering || 'none';
if (style === 'none') return '\\pagenumbering{gobble}\n';
// The Ch 9 page continuation, gated to endorsement types exactly like the
// reference/enclosure continuations — this was previously ungated, so a
// startingPageNumber left behind by an endorsement silently offset ANY
// doc type's pages in the Word export.
const start = pageStartNumber(docType, data.startingPageNumber);
const startPage = start > 1 ? `\\setcounter{page}{${start}}\n` : '';
// SECNAV: no page number on the FIRST page of a document — but a continued
// sequence is not a first page: Fig 9-2 prints the number on the
// endorsement's own sheet ("2"), so suppression only applies when the
// sequence actually starts at 1.
const suppressFirst = startPage ? '' : '\\thispagestyle{plain}\n';
// Use right footer to avoid conflict with classification markings in center footer
return `\\fancypagestyle{plain}{\\fancyhf{}}\n${suppressFirst}\\fancyfoot[R]{\\thepage}\n${startPage}`;
}
/** Centered letterhead using tabularX (pandoc renders center alignment in DOCX) */
function buildLetterhead(data: Partial<DocumentData>): string {
const dept = getDepartmentName(data.department);
const unit1 = escapeTabular(data.unitLine1);
const unit2 = data.unitLine2?.trim() ? escapeTabular(data.unitLine2) : '';
// Split `unitAddress` into letterhead lines via the shared helper
// (single source of truth — generator.ts uses the same).
const { line1: addr1, line2: addr2 } = splitAddressForLetterhead(data.unitAddress || '');
// Determine seal filename: {sealType}-seal{-bw if black}.png
const sealType = data.sealType || 'dow';
const bwSuffix = data.letterheadColor === 'black' ? '-bw' : '';
const sealFile = `${sealType}-seal${bwSuffix}.png`;
// Per SECNAV M-5216.5 App C §2a (Computer Generated Letterhead):
// Department line: 10pt bold, colored (PMS 288 navy blue or black)
// Activity/unit name: 8pt, colored (NOT bold — App C §1d(2))
// Address lines: 8pt, colored
const color = data.letterheadColor === 'black' ? 'black' : 'navyblue';
// Build center-column content using \newline for line breaks within a cell.
// NOTE: We cannot use a nested \begin{tabular}{c} here because pandoc
// flattens nested tables into separate blocks, breaking the 3-column layout.
// Font size commands: \fontsize{size}{baselineskip}\selectfont
const lines: string[] = [];
lines.push(`{\\fontsize{10pt}{11pt}\\selectfont\\textcolor{${color}}{\\textbf{${dept}}}}`);
lines.push(`{\\fontsize{8pt}{9pt}\\selectfont\\textcolor{${color}}{${unit1}}}`);
if (unit2) lines.push(`{\\fontsize{8pt}{9pt}\\selectfont\\textcolor{${color}}{${unit2}}}`);
lines.push(`{\\fontsize{8pt}{9pt}\\selectfont\\textcolor{${color}}{${escapeTabular(addr1)}}}`);
if (addr2) lines.push(`{\\fontsize{8pt}{9pt}\\selectfont\\textcolor{${color}}{${escapeTabular(addr2)}}}`);
const centerContent = lines.join(' \\newline\n');
// 3-column layout: seal | centered org text | right spacer (mirrors seal width)
// Equal left/right fixed columns center the org text on the full page width.
// The Lua filter detects this as a letterhead table (Image in first cell)
// and forces AlignCenter on the middle column for DOCX output.
const sealColIn = (LAYOUT.letterhead.sealCol * TEXT_WIDTH_IN).toFixed(2);
return `\\noindent
\\begin{tabularx}{\\textwidth}{@{}p{${sealColIn}in}@{}X@{}p{${sealColIn}in}@{}}
\\includegraphics[width=1.09in]{${sealFile}} & ${centerContent} & \\\\
\\end{tabularx}
\\vspace{12pt}
`;
}
/** Right-aligned SSIC/Serial/Date block using tabularX */
function buildSSICBlock(data: Partial<DocumentData>, alignRight = true): string {
const items: string[] = [];
if (data.ssic) items.push(escapeTabular(data.ssic));
if (data.serial) items.push(escapeTabular(data.serial));
if (data.date) items.push(escapeTabular(data.date));
if (items.length === 0) return '';
if (alignRight) {
const rows = items.map(item => ` & ${item} \\\\`).join('\n');
return `\\noindent
\\begin{tabularx}{\\textwidth}{@{}X@{}l@{}}
${rows}
\\end{tabularx}
\\vspace{12pt}
`;
}
// Left-aligned: simple tabular (matches PDF templates that use \hfill at paragraph boundary)
const rows = items.map(item => `${item} \\\\`).join('\n');
return `\\noindent
\\begin{tabular}{@{}l@{}}
${rows}
\\end{tabular}
\\vspace{12pt}
`;
}
function buildInReplyTo(data: Partial<DocumentData>): string {
if (!data.inReplyTo || !data.inReplyToText) return '';
return `\\noindent
\\begin{tabularx}{\\textwidth}{@{}Xr@{}}
& In reply refer to: \\\\
& ${escapeTabular(data.inReplyToText)} \\\\
\\end{tabularx}
`;
}
/** Wrap text at a character limit without breaking words (for Subject/From/To lines).
* Per SECNAV M-5216.5 subject lines wrap at ~57 characters. */
function wrapText(str: string, maxLength: number = 57): string[] {
if (!str) return [];
const lines: string[] = [];
let i = 0;
while (i < str.length) {
let chunk = str.substring(i, i + maxLength);
if (i + maxLength < str.length && str[i + maxLength] !== ' ' && chunk.includes(' ')) {
const lastSpace = chunk.lastIndexOf(' ');
if (lastSpace > -1) {
chunk = chunk.substring(0, lastSpace);
i += chunk.length + 1;
} else {
i += maxLength;
}
} else {
i += maxLength;
}
lines.push(chunk.trim());
}
return lines;
}
/** Escape and wrap a tabular cell value, joining wrapped lines with \\newline */
function escapeTabularWrapped(str: string | undefined | null, maxLength: number = 57): string {
if (!str) return '';
const wrapped = wrapText(str, maxLength);
return wrapped.map(l => escapeTabular(l)).join(' \\newline\n');
}
/** Conditionally wrap subject text in \\uline{} based on the underlineSubject flag. */
function maybeUnderline(text: string, underline: boolean | undefined): string {
return underline ? `\\uline{${text}}` : text;
}
/** Strip trailing \\\\ (with optional spacing param) from the last row of a tabular
* to avoid creating an empty row at the bottom. Matches the PDF template fix
* where trailing \tabularnewline was removed before \end{tabular}. */
function trimLastRow(rows: string[]): string {
if (rows.length === 0) return '';
const result = [...rows];
result[result.length - 1] = result[result.length - 1].replace(/ \\\\(\[-?\d+pt\])?$/, '');
return result.join('\n');
}
/** Address block (From/To/Via/Subj) using tabular for proper alignment.
* Colon spacing per SECNAV Ch 7: From=2sp, To=6sp, Via=5sp, Subj=3sp */
function buildAddressBlock(data: Partial<DocumentData>, config: DocTypeConfig): string {
const rows: string[] = [];
if (config.fromTo) {
if (data.from) rows.push(`From:\\hspace{2\\fontdimen2\\font} & ${escapeTabularWrapped(data.from)} \\\\`);
if (data.to) rows.push(`To:\\hspace{6\\fontdimen2\\font} & ${escapeTabularWrapped(data.to)} \\\\`);
}
if (config.via && data.via?.trim()) {
// formatViaLines applies the Ch 9 ¶2 numbering — single source shared
// with the PDF path (generator.ts), so the two outputs cannot drift.
const viaLines = formatViaLines(data.via);
for (let i = 0; i < viaLines.length; i++) {
if (i === 0) {
rows.push(`Via:\\hspace{5\\fontdimen2\\font} & ${escapeTabularWrapped(viaLines[i])} \\\\`);
} else {
rows.push(` & ${escapeTabularWrapped(viaLines[i])} \\\\`);
}
}
}
if (data.subject && !config.skipSubject) {
// 12pt space before Subj (matches PDF's \tabularnewline[12pt] in templates)
// Use explicit empty spacer row because pandoc ignores \\[12pt] row spacing
if (rows.length > 0) {
rows.push(`& \\\\`);
}
rows.push(`Subj:\\hspace{3\\fontdimen2\\font} & ${maybeUnderline(escapeTabularWrapped(data.subject?.toUpperCase()), data.underlineSubject)} \\\\`);
}
if (rows.length === 0) return '';
return `\\noindent
\\begin{tabular}{@{}l@{}p{5.75in}@{}}
${trimLastRow(rows)}
\\end{tabular}
`;
}
/** References using tabular for proper hanging-indent alignment.
* Colon spacing per SECNAV Ch 7 ¶10c: Ref=4sp */
function buildReferences(references: Reference[]): string {
if (references.length === 0) return '';
const rows: string[] = [];
for (let i = 0; i < references.length; i++) {
const ref = references[i];
if (i === 0) {
rows.push(`Ref:\\hspace{4\\fontdimen2\\font} & (${ref.letter})~~${escapeTabular(ref.title)} \\\\`);
} else {
rows.push(` & (${ref.letter})~~${escapeTabular(ref.title)} \\\\`);
}
}
// 12pt before Ref block (matches PDF's \vspace{12pt} in main.tex printReferences)
return `\\vspace{12pt}
\\noindent
\\begin{tabular}{@{}l@{}p{5.75in}@{}}
${trimLastRow(rows)}
\\end{tabular}
`;
}
/** Enclosures using tabular for proper hanging-indent alignment.
* Colon spacing per SECNAV Ch 7 ¶11b: Encl=3sp */
function buildEnclosures(enclosures: Enclosure[], startNumber = 1): string {
if (enclosures.length === 0) return '';
const rows: string[] = [];
for (let i = 0; i < enclosures.length; i++) {
const encl = enclosures[i];
// `startNumber` lets an endorsement continue the basic letter's enclosure
// numbering (Ch 9 ¶4); it is 1 for everything that opens its own sequence.
const n = startNumber + i;
if (i === 0) {
rows.push(`Encl:\\hspace{3\\fontdimen2\\font} & (${n})~~${escapeTabular(encl.title)} \\\\`);
} else {
rows.push(` & (${n})~~${escapeTabular(encl.title)} \\\\`);
}
}
// 12pt before Encl block (matches PDF's \vspace{12pt} in main.tex printEnclosureList)
return `\\vspace{12pt}
\\noindent
\\begin{tabular}{@{}l@{}p{5.75in}@{}}
${trimLastRow(rows)}
\\end{tabular}
`;
}
/** Body paragraphs using \mbox{} to protect labels from pandoc list detection.
*
* Indentation per SECNAV M-5216.5 Ch 7 ¶13:
* Standard: level 0 = flush left; subparagraphs indent 0.25in per level
* Business: level 0 = 0.5in first-line indent; subparagraphs += 0.5in per level
*/
function buildBody(paragraphs: Paragraph[], config: DocTypeConfig): string {
if (paragraphs.length === 0) return '';
const labels = calculateLabels(paragraphs);
const useNumbered = config.compliance.numberedParagraphs;
// Push-then-join across paragraphs (the cross-paragraph accumulator is
// the one that grows unbounded — within a single paragraph the
// accumulator stays small so we leave that alone).
const bodyParts: string[] = [];
for (let i = 0; i < paragraphs.length; i++) {
const para = paragraphs[i];
const label = useNumbered ? labels[i] : '';
const headerText = para.header?.trim();
const portionPrefix = para.portionMarking ? `(${para.portionMarking}) ` : '';
// 12pt for level 0 paragraphs, 6pt for sub-paragraphs
const spacing = para.level === 0 ? '\\vspace{12pt}' : '\\vspace{6pt}';
let paraText = '';
// Portion marking
if (portionPrefix) paraText += portionPrefix;
// Optional underlined header
if (headerText) {
paraText += `${underlineWords(escapeFlat(toTitleCase(stripHeaderPunctuation(headerText))))}. `;
}
// Body text with rich text processing
paraText += processText(para.text);
// Calculate indentation based on level and document type
// \dondocsindent → w:ind w:left (full paragraph indent)
// \dondocsfirstindent → w:ind w:firstLine (first-line only, like \parindent)
const isBusiness = config.uiMode === 'business';
const indentIn = isBusiness
? (para.level + 1) * 0.5 // Business: 0.5in per level, starting at 0.5in
: para.level * 0.25; // Standard: 0.25in per level (level 0 = flush left)
const indentCmd = indentIn > 0 ? `\\dondocsindent{${indentIn.toFixed(2)}in}` : '';
if (isBusiness) {
// Business letter: first-line indent for level 0, full indent for deeper levels
const bizIndentCmd = para.level === 0
? `\\dondocsfirstindent{0.50in}`
: indentCmd;
bodyParts.push(`${spacing}\n${bizIndentCmd}${paraText}\n\n`);
} else if (label) {
// Use \mbox{} to protect labels like "1." from pandoc's list marker detection.
// The Lua filter's RawInline handler converts \mbox{} to plain text for DOCX.
bodyParts.push(`${spacing}\n${indentCmd}\\mbox{${label}}~~${paraText}\n\n`);
} else {
// No label (unnumbered paragraphs, e.g. endorsements)
bodyParts.push(`${spacing}\n${indentCmd}${paraText}\n\n`);
}
}
return bodyParts.join('');
}
/**
* The appointee's acknowledgement below the letter's signature: a rule across
* the page, the endorsement line, its own From/To, the body, and the signer.
* Mirrors `\printAppendedEndorsement` in tex/main.tex — the two must produce
* the same page, so a change here needs the same change there.
*/
function buildAppendedEndorsement(store: DocumentStore): string {
const ack = resolveAppendedEndorsement(store.docType, store.formData);
if (!ack) return '';
// \mbox{} protects the label from pandoc's list-marker detection, exactly as
// the main body does — without it pandoc consumed the digit and the DOCX
// rendered a bare "." where the PDF showed "1.".
const body = ack.paragraphs
.map((text, i) => `\\noindent \\mbox{${i + 1}.}~~${escapeFlat(text)}\n\n`)
.join('');
const signer = appendedEndorsementSigner(store.formData);
// Ch 9 para 2.1a: the endorsement line sits below the date line, which the
// same-page omission list does not cover (Fig 9-1 shows it). Serial is
// optional; both stay blank when the appointee hand-dates at signature.
//
// tabularx{Xr} for the date and tabularx{X@{}l} for the signer, not \hfill
// and \hspace*{3.25in}: dondocs.lua understands only \dondocsindent and drops
// all other raw LaTeX, so both commands vanished — the DOCX left-aligned the
// date and signed the appointee at the margin while the officer signed at
// 3.25in. These are the idioms the letter's own date and signature use, and
// the ones this file's header prescribes ("tabularX{Xr} for right-aligned
// content"). The 0.5pt rule matches the DOCX endorsement divider below.
const dateRows = [ack.serial, ack.date]
.filter(Boolean)
.map((row) => ` & ${escapeTabular(row)} \\\\`)
.join('\n');
return `\\vspace{24pt}
\\noindent
\\rule{\\textwidth}{0.5pt}
\\vspace{12pt}
\\noindent
\\begin{tabularx}{\\textwidth}{@{}Xr@{}}
${dateRows}
\\end{tabularx}
\\vspace{12pt}
\\noindent FIRST ENDORSEMENT
\\vspace{12pt}
\\noindent
\\begin{tabular}{@{}l@{}p{5.75in}@{}}
From:\\hspace{2\\fontdimen2\\font} & ${escapeTabular(ack.from)} \\\\
To:\\hspace{6\\fontdimen2\\font} & ${escapeTabular(ack.to)} \\\\
\\end{tabular}
\\vspace{12pt}
${body}\\vspace{48pt}
\\noindent
\\begin{tabularx}{\\textwidth}{@{}X@{}l@{}}
& ${escapeTabular(signer)} \\\\
\\end{tabularx}
`;
}
/** "By direction" line per SECNAV M-5216.5 Ch 7 ¶14b(4)-(5): the bare form is
* the norm. The "of the <activity head>" long form is reserved for
* correspondence affecting pay and allowances, so it appears only when an
* authority is actually named — never as a default. */
function buildByDirectionLine(authority: string | undefined): string {
const named = (authority || '').trim();
return named ? `By direction of ${escapeTabular(named)}` : 'By direction';
}
/** Single signature block positioned in right half using tabularX */
function buildSignature(data: Partial<DocumentData>, config: DocTypeConfig): string {
const sigStyle = config.signature;
let name: string;
if (sigStyle === 'abbrev') {
name = abbreviateName(data.sigFirst, data.sigMiddle, data.sigLast);
} else {
name = buildFullName(data.sigFirst, data.sigMiddle, data.sigLast);
}
const sigRows: string[] = [];
sigRows.push(escapeTabular(name));
// Show rank/title unless config says name-only (e.g., standard_letter, plain_paper_memorandum)
if (config.showSignatureRankTitle !== false) {
if (data.sigRank) sigRows.push(escapeTabular(data.sigRank));
if (data.sigTitle) sigRows.push(escapeTabular(data.sigTitle));
}
if (data.byDirection) {
sigRows.push(buildByDirectionLine(data.byDirectionAuthority));
}
const rows = sigRows.map(r => ` & ${r} \\\\`).join('\n');
const sigSpacing = config.signatureSpacing || '48pt';
return `\\vspace{${sigSpacing}}
\\noindent
\\begin{tabularx}{\\textwidth}{@{}X@{}l@{}}
${rows}
\\end{tabularx}
`;
}
/** Business letter signature (centered with complimentary close)
* Per SECNAV Ch 11, Para 11-10-12: close and signature block centered */
function buildBusinessSignature(data: Partial<DocumentData>): string {
const name = buildFullName(data.sigFirst, data.sigMiddle, data.sigLast);
const close = escapeTabular(data.complimentaryClose || 'Sincerely,');
// Build centered signature rows
const sigRows: string[] = [];
sigRows.push(escapeTabular(name));
if (data.sigRank) sigRows.push(escapeTabular(data.sigRank));
if (data.sigTitle) sigRows.push(escapeTabular(data.sigTitle));
if (data.byDirection) {
sigRows.push(buildByDirectionLine(data.byDirectionAuthority));
}
// Use tabularX{XcX} for centering (pandoc ignores \begin{center})
const sig = `\\vspace{24pt}
\\noindent
\\begin{tabularx}{\\textwidth}{@{}XcX@{}}
& ${close} & \\\\
\\end{tabularx}
\\vspace{48pt}
\\noindent
\\begin{tabularx}{\\textwidth}{@{}XcX@{}}
${trimLastRow(sigRows.map(r => ` & ${r} & \\\\`))}
\\end{tabularx}
`;
return sig;
}
/** Dual signature block using tabularX with two columns.
* MOA/MOU: overscored (horizontal rule above name) per SECNAV M-5216.5 Fig 10-5
* Joint/Joint Memo: no overscoring per Ch 7 Fig 7-4 */
function buildDualSignature(data: Partial<DocumentData>, variant: 'moa' | 'joint' | 'joint_memo'): string {
// juniorRank/seniorRank only populate in the 'moa' branch (joint variants
// don't carry rank); default to '' so the rank-row rendering below still
// works on joint variants. juniorName/Title and seniorName/Title are
// always assigned in both branches.
let juniorName: string, juniorTitle: string;
let seniorName: string, seniorTitle: string;
let juniorRank = '';
let seniorRank = '';
if (variant === 'moa') {
const junFirst = capitalizeWord(data.juniorSigName?.split(' ')[0]);
const junLast = data.juniorSigName?.split(' ').slice(-1)[0]?.toUpperCase() || '';
juniorName = junFirst ? `${junFirst[0]}. ${junLast}` : junLast;
juniorRank = data.juniorSigRank || '';
juniorTitle = data.juniorSigTitle || '';
const senFirst = capitalizeWord(data.seniorSigName?.split(' ')[0]);
const senLast = data.seniorSigName?.split(' ').slice(-1)[0]?.toUpperCase() || '';
seniorName = senFirst ? `${senFirst[0]}. ${senLast}` : senLast;
seniorRank = data.seniorSigRank || '';
seniorTitle = data.seniorSigTitle || '';
} else {
// Joint letter and joint memo share the same fields
juniorName = data.jointJuniorSigName?.toUpperCase() || '';
juniorTitle = data.jointJuniorSigTitle || '';
seniorName = data.jointSeniorSigName?.toUpperCase() || '';
seniorTitle = data.jointSeniorSigTitle || '';
}
// MOA/MOU: overscored signatures (rule above name)
// Per SECNAV M-5216.5 Fig 10-5: junior LEFT (signs first), senior RIGHT (signs last)
// Use p{3in}@{\hfill}p{3in} — same columns as SSIC/date block for alignment.
// Name/rank/title cells use \centering to center text under the signature line.
// Pandoc converts \centering in p{} cells to centered paragraph alignment in DOCX.
if (variant === 'moa') {
const sigLine = '\\_'.repeat(24); // ~2in signature line at 12pt
const rows: string[] = [];
// Row 1: overscore lines (left-aligned within column, matching date alignment)
rows.push(`${sigLine} & ${sigLine} \\\\`);
// Remaining rows: centered text under each signature line
rows.push(`\\centering ${escapeTabular(juniorName)} & \\centering ${escapeTabular(seniorName)} \\\\`);
if (juniorRank || seniorRank) {
rows.push(`\\centering ${escapeTabular(juniorRank)} & \\centering ${escapeTabular(seniorRank)} \\\\`);
}
if (juniorTitle || seniorTitle) {
rows.push(`\\centering ${escapeTabular(juniorTitle)} & \\centering ${escapeTabular(seniorTitle)} \\\\`);
}
return `\\vspace{48pt}
\\noindent
\\begin{tabular}{@{}p{3in}@{\\hfill}p{3in}@{}}
${trimLastRow(rows)}
\\end{tabular}
`;
}
// Joint/Joint Memo: no overscoring, separate rows for each sig field.
// Per SECNAV M-5216.5 Ch 7 Fig 7-4: Two signature blocks side by side.
// Uses p{2.75in} columns so pandoc constrains column width and wraps long text.
// Each field (name, rank, each title line) is a separate table row so pandoc
// reliably creates line breaks (pandoc ignores \newline inside p{} cells).
// Title fields support multi-line input from the UI textarea — each \n-separated
// line becomes its own row (e.g., "Captain, U.S. Navy\nCommanding Officer" → 2 rows).
const rows: string[] = [];
rows.push(`${escapeTabular(juniorName)} & ${escapeTabular(seniorName)} \\\\`);
if (juniorRank || seniorRank) {
rows.push(`${escapeTabular(juniorRank)} & ${escapeTabular(seniorRank)} \\\\`);
}
// Split multi-line titles into separate rows
const juniorTitleLines = juniorTitle ? juniorTitle.split('\n').filter(l => l.trim()) : [];
const seniorTitleLines = seniorTitle ? seniorTitle.split('\n').filter(l => l.trim()) : [];
const maxTitleLines = Math.max(juniorTitleLines.length, seniorTitleLines.length);
for (let i = 0; i < maxTitleLines; i++) {
const jLine = juniorTitleLines[i] || '';
const sLine = seniorTitleLines[i] || '';
rows.push(`${escapeTabular(jLine)} & ${escapeTabular(sLine)} \\\\`);
}
// Use p{3in}@{\hfill}p{3in} to match PDF template (joint_letter.tex \printSignature)
// and stay consistent with the SSIC block layout above.
return `\\vspace{48pt}
\\noindent
\\begin{tabular}{@{}p{3in}@{\\hfill}p{3in}@{}}
${trimLastRow(rows)}
\\end{tabular}
`;
}
/** Copy-to list using address-style tabular (label | content).
* The Lua filter detects "Copy to:" as an address label and applies
* the correct label/content column proportions (11.5% / 88.5%). */
function buildCopyTo(copyTos: CopyTo[]): string {
if (copyTos.length === 0) return '';
const rows: string[] = [];
for (let i = 0; i < copyTos.length; i++) {
if (i === 0) {
rows.push(`Copy to: & ${escapeTabular(copyTos[i].text)} \\\\`);
} else {
rows.push(` & ${escapeTabular(copyTos[i].text)} \\\\`);
}
}
return `\\vspace{12pt}
\\noindent
\\begin{tabular}{@{}l@{\\hspace{2\\fontdimen2\\font}}p{5.5in}@{}}
${trimLastRow(rows)}
\\end{tabular}
`;
}
/** Distribution list using address-style tabular (label | content).
* Mirrors buildCopyTo but with "Distribution:" label for action addressees. */
function buildDistribution(distributions: Distribution[]): string {
if (distributions.length === 0) return '';
const rows: string[] = [];
for (let i = 0; i < distributions.length; i++) {
if (i === 0) {
rows.push(`Distribution: & ${escapeTabular(distributions[i].text)} \\\\`);
} else {
rows.push(` & ${escapeTabular(distributions[i].text)} \\\\`);
}
}
return `\\vspace{12pt}
\\noindent
\\begin{tabular}{@{}l@{\\hspace{2\\fontdimen2\\font}}p{5.5in}@{}}
${trimLastRow(rows)}
\\end{tabular}
`;
}
/** CUI marking block */
function buildCUIBlock(data: Partial<DocumentData>): string {
if (data.classLevel !== 'cui') return '';
let block = '\\vspace{24pt}\n\\noindent\n\\rule{\\textwidth}{0.5pt}\n\n';
block += '\\textbf{CUI}\n\n';
if (data.cuiControlledBy) block += `Controlled by: ${escapeFlat(data.cuiControlledBy)}\n\n`;
if (data.cuiCategory) block += `CUI Category: ${escapeFlat(data.cuiCategory)}\n\n`;
if (data.cuiDissemination) block += `Limited Dissemination Control: ${escapeFlat(data.cuiDissemination)}\n\n`;
if (data.cuiDistStatement) block += `Distribution Statement: ${escapeFlat(data.cuiDistStatement)}\n\n`;
if (data.pocEmail) block += `POC: ${escapeFlat(data.pocEmail)}\n\n`;
return block;
}
/** Classified marking block */
function buildClassifiedBlock(data: Partial<DocumentData>): string {
if (!data.classLevel || data.classLevel === 'unclassified' || data.classLevel === 'cui' || data.classLevel === 'custom') return '';
let block = '\\vspace{24pt}\n\\noindent\n\\rule{\\textwidth}{0.5pt}\n\n';
if (data.classifiedBy) block += `Classified by: ${escapeFlat(data.classifiedBy)}\n\n`;
if (data.derivedFrom) block += `Derived from: ${escapeFlat(data.derivedFrom)}\n\n`;
if (data.declassifyOn) block += `Declassify on: ${escapeFlat(data.declassifyOn)}\n\n`;
if (data.classReason) block += `Reason: ${escapeFlat(data.classReason)}\n\n`;
if (data.classifiedPocEmail) block += `POC: ${escapeFlat(data.classifiedPocEmail)}\n\n`;
return block;
}
/** Centered memo header using tabularX */
function buildMemoHeader(config: DocTypeConfig, data?: Partial<DocumentData>): string {
let title = config.memoTitle || 'MEMORANDUM';
// MF: addressee is embedded in the title ("MEMORANDUM FOR [addressee]")
if (config.memoTitle === 'MEMORANDUM FOR' && data?.to) {
title += ` ${escapeTabular(data.to)}`;
}
return `\\noindent
\\begin{tabularx}{\\textwidth}{@{}X@{}c@{}X@{}}
& \\textbf{${title}} & \\\\
\\end{tabularx}
`;
}
function buildDecisionBlock(): string {
// Decision block: APPROVED/DISAPPROVED signature lines.
// Use simple paragraph layout instead of tabular — pandoc handles \rule
// inside tabular inconsistently, but standalone \rule works reliably.
// Each label + signature line on its own paragraph for clean DOCX output.
return `\\vspace{24pt}
\\noindent
\\rule{\\textwidth}{0.5pt}
\\vspace{12pt}
\\noindent APPROVED:\\hspace{1em}\\rule{3in}{0.5pt}
\\vspace{24pt}
\\noindent DISAPPROVED:\\hspace{1em}\\rule{3in}{0.5pt}
`;
}
/** Centered MOA/MOU title using tabularX with \newline breaks.
* NOTE: We must NOT use nested \begin{tabular}{c} here because pandoc
* flattens nested tables into separate blocks, breaking the centered layout.
* Instead we use \newline for line breaks within the center cell (same
* pattern as buildLetterhead and buildMemoHeader). */
function buildMOATitle(data: Partial<DocumentData>, docType: string): string {
const type = docType === 'mou' ? 'UNDERSTANDING' : 'AGREEMENT';
const lines = [
`\\textbf{MEMORANDUM OF ${type}}`,
'\\textbf{BETWEEN}',
`\\textbf{${escapeTabular(data.seniorCommandName?.toUpperCase())}}`,
'\\textbf{AND}',
`\\textbf{${escapeTabular(data.juniorCommandName?.toUpperCase())}}`,
];
const centerContent = lines.join(' \\newline\n');
// 12pt space before title (after SSIC block) and after title (before Subj)
return `\\vspace{12pt}
\\noindent
\\begin{tabularx}{\\textwidth}{@{}X@{}c@{}X@{}}
& ${centerContent} & \\\\
\\end{tabularx}
\\vspace{12pt}
`;
}
/** MOA dual SSIC blocks — junior on left, senior on right.
* Per SECNAV M-5216.5: Junior command LEFT (signs first), Senior command RIGHT (signs last).
* Matches signature block positioning and the fixed PDF templates (moa.tex/mou.tex). */
function buildMOASSICBlock(data: Partial<DocumentData>): string {
// Junior command on LEFT (signs first)
const leftItems: string[] = [];
if (data.juniorSSIC) leftItems.push(escapeTabular(data.juniorSSIC));
if (data.juniorSerial) leftItems.push(escapeTabular(data.juniorSerial));
if (data.juniorDate) leftItems.push(escapeTabular(data.juniorDate));
// Senior command on RIGHT (signs last)
const rightItems: string[] = [];
if (data.seniorSSIC || data.ssic) rightItems.push(escapeTabular(data.seniorSSIC || data.ssic));
if (data.seniorSerial || data.serial) rightItems.push(escapeTabular(data.seniorSerial || data.serial));
if (data.seniorDate || data.date) rightItems.push(escapeTabular(data.seniorDate || data.date));
const maxRows = Math.max(leftItems.length, rightItems.length);
const rows: string[] = [];
for (let i = 0; i < maxRows; i++) {
rows.push(`${leftItems[i] || ''} & ${rightItems[i] || ''} \\\\`);
}