305 lines
13 KiB
PHP
305 lines
13 KiB
PHP
<?php
|
|
|
|
function escape_xml(string $text): string {
|
|
return htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
function make_run(string $text, bool $bold = false, bool $italic = false, int $fontSize = 22, ?string $fontColor = null, string $fontName = 'Calibri', bool $isCode = false): string {
|
|
$rPr = [];
|
|
$rPr[] = "<w:rFonts w:ascii=\"{$fontName}\" w:hAnsi=\"{$fontName}\" w:cs=\"{$fontName}\"/>";
|
|
if ($bold || $isCode) {
|
|
$rPr[] = '<w:b/>';
|
|
}
|
|
if ($italic) {
|
|
$rPr[] = '<w:i/>';
|
|
}
|
|
if ($fontSize) {
|
|
$rPr[] = "<w:sz w:val=\"{$fontSize}\"/><w:szCs w:val=\"{$fontSize}\"/>";
|
|
}
|
|
if ($fontColor) {
|
|
$rPr[] = "<w:color w:val=\"{$fontColor}\"/>";
|
|
}
|
|
if ($isCode) {
|
|
$rPr[] = '<w:highlight w:val="lightGray"/>';
|
|
$rPr[] = '<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>';
|
|
}
|
|
$rPrXml = !empty($rPr) ? '<w:rPr>' . implode('', $rPr) . '</w:rPr>' : '';
|
|
return '<w:r>' . $rPrXml . '<w:t xml:space="preserve">' . escape_xml($text) . '</w:t></w:r>';
|
|
}
|
|
|
|
function parse_inline_formatting(string $line, int $defaultSize = 22, string $defaultColor = '333333'): string {
|
|
$parts = preg_split('/(\*\*.*?\*\*|`.*?`)/u', $line, -1, PREG_SPLIT_DELIM_CAPTURE);
|
|
$runs = [];
|
|
foreach ($parts as $part) {
|
|
if ($part === '') continue;
|
|
if (str_starts_with($part, '**') && str_ends_with($part, '**') && strlen($part) >= 4) {
|
|
$runs[] = make_run(substr($part, 2, -2), bold: true, fontSize: $defaultSize, fontColor: $defaultColor);
|
|
} elseif (str_starts_with($part, '`') && str_ends_with($part, '`') && strlen($part) >= 2) {
|
|
$runs[] = make_run(substr($part, 1, -1), isCode: true, fontSize: $defaultSize - 2, fontColor: '0F172A');
|
|
} else {
|
|
$runs[] = make_run($part, fontSize: $defaultSize, fontColor: $defaultColor);
|
|
}
|
|
}
|
|
return implode('', $runs);
|
|
}
|
|
|
|
function make_paragraph(string $runsXml, ?string $style = null, ?string $align = null, int $spaceBefore = 100, int $spaceAfter = 100, ?string $bgColor = null): string {
|
|
$pPr = [];
|
|
if ($style) {
|
|
$pPr[] = "<w:pStyle w:val=\"{$style}\"/>";
|
|
}
|
|
if ($align) {
|
|
$pPr[] = "<w:jc w:val=\"{$align}\"/>";
|
|
}
|
|
$pPr[] = "<w:spacing w:before=\"{$spaceBefore}\" w:after=\"{$spaceAfter}\" w:line=\"276\" w:lineRule=\"auto\"/>";
|
|
if ($bgColor) {
|
|
$pPr[] = "<w:shd w:val=\"clear\" w:color=\"auto\" w:fill=\"{$bgColor}\"/>";
|
|
}
|
|
$pPrXml = !empty($pPr) ? '<w:pPr>' . implode('', $pPr) . '</w:pPr>' : '';
|
|
return '<w:p>' . $pPrXml . $runsXml . '</w:p>';
|
|
}
|
|
|
|
function make_table(array $rows, bool $headers = true): string {
|
|
if (empty($rows)) return '';
|
|
$numCols = 0;
|
|
foreach ($rows as $r) {
|
|
$numCols = max($numCols, count($r));
|
|
}
|
|
$colWidth = (int) (9000 / max(1, $numCols));
|
|
|
|
$tbl = [];
|
|
$tbl[] = '<w:tbl>';
|
|
$tbl[] = '<w:tblPr>
|
|
<w:tblStyle w:val="TableGrid"/>
|
|
<w:tblW w:w="9000" w:type="dxa"/>
|
|
<w:tblBorders>
|
|
<w:top w:val="single" w:sz="4" w:space="0" w:color="CBD5E1"/>
|
|
<w:left w:val="single" w:sz="4" w:space="0" w:color="CBD5E1"/>
|
|
<w:bottom w:val="single" w:sz="4" w:space="0" w:color="CBD5E1"/>
|
|
<w:right w:val="single" w:sz="4" w:space="0" w:color="CBD5E1"/>
|
|
<w:insideH w:val="single" w:sz="4" w:space="0" w:color="E2E8F0"/>
|
|
<w:insideV w:val="single" w:sz="4" w:space="0" w:color="E2E8F0"/>
|
|
</w:tblBorders>
|
|
<w:tblCellMar>
|
|
<w:top w:w="140" w:type="dxa"/>
|
|
<w:bottom w:w="140" w:type="dxa"/>
|
|
<w:left w:w="180" w:type="dxa"/>
|
|
<w:right w:w="180" w:type="dxa"/>
|
|
</w:tblCellMar>
|
|
</w:tblPr>';
|
|
|
|
$tbl[] = '<w:tblGrid>';
|
|
for ($i = 0; $i < $numCols; $i++) {
|
|
$tbl[] = "<w:gridCol w:w=\"{$colWidth}\"/>";
|
|
}
|
|
$tbl[] = '</w:tblGrid>';
|
|
|
|
foreach ($rows as $rowIdx => $row) {
|
|
$isHeader = ($rowIdx === 0 && $headers);
|
|
$tbl[] = '<w:tr>';
|
|
$trPr = '<w:trPr><w:cantSplit/>';
|
|
if ($isHeader) {
|
|
$trPr .= '<w:tblHeader/>';
|
|
}
|
|
$trPr .= '</w:trPr>';
|
|
$tbl[] = $trPr;
|
|
|
|
for ($c = 0; $c < $numCols; $c++) {
|
|
$cellText = $row[$c] ?? '';
|
|
$cellBg = $isHeader ? 'E2E8F0' : ($rowIdx % 2 === 1 ? 'FFFFFF' : 'F8FAFC');
|
|
$tcPr = "<w:tcPr>
|
|
<w:tcW w:w=\"{$colWidth}\" w:type=\"dxa\"/>
|
|
<w:shd w:val=\"clear\" w:color=\"auto\" w:fill=\"{$cellBg}\"/>
|
|
<w:vAlign w:val=\"center\"/>
|
|
</w:tcPr>";
|
|
$tbl[] = '<w:tc>' . $tcPr;
|
|
$runs = parse_inline_formatting(trim($cellText), $isHeader ? 19 : 18, $isHeader ? '0F172A' : '334155');
|
|
$tbl[] = make_paragraph($runs, spaceBefore: 40, spaceAfter: 40);
|
|
$tbl[] = '</w:tc>';
|
|
}
|
|
$tbl[] = '</w:tr>';
|
|
}
|
|
|
|
$tbl[] = '</w:tbl>';
|
|
return implode('', $tbl);
|
|
}
|
|
|
|
function convert_markdown_to_docx(string $mdFile, string $docxFile): void {
|
|
if (!file_exists($mdFile)) {
|
|
fwrite(STDERR, "Error: Source markdown file not found: {$mdFile}\n");
|
|
exit(1);
|
|
}
|
|
|
|
$mdContent = file_get_contents($mdFile);
|
|
$lines = explode("\n", $mdContent);
|
|
$docElements = [];
|
|
|
|
$inCodeBlock = false;
|
|
$codeLines = [];
|
|
$inTable = false;
|
|
$tableRows = [];
|
|
|
|
foreach ($lines as $line) {
|
|
$trimmed = trim($line);
|
|
|
|
// Code block
|
|
if (str_starts_with($trimmed, '```')) {
|
|
if ($inCodeBlock) {
|
|
$inCodeBlock = false;
|
|
$codeText = implode("\n", $codeLines);
|
|
$codeRuns = make_run($codeText, fontName: 'Consolas', fontSize: 17, fontColor: '1E293B');
|
|
$docElements[] = make_paragraph($codeRuns, spaceBefore: 120, spaceAfter: 120, bgColor: 'F1F5F9');
|
|
$codeLines = [];
|
|
} else {
|
|
$inCodeBlock = true;
|
|
$codeLines = [];
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($inCodeBlock) {
|
|
$codeLines[] = $line;
|
|
continue;
|
|
}
|
|
|
|
// Table
|
|
if (str_contains($line, '|') && (str_starts_with($trimmed, '|') || str_ends_with($trimmed, '|'))) {
|
|
if (preg_match('/^[\|\s\-\:]+$/', $trimmed)) {
|
|
continue; // Table separator line
|
|
}
|
|
$cells = array_map('trim', explode('|', $trimmed));
|
|
if (!empty($cells) && $cells[0] === '') array_shift($cells);
|
|
if (!empty($cells) && end($cells) === '') array_pop($cells);
|
|
$tableRows[] = $cells;
|
|
$inTable = true;
|
|
continue;
|
|
} else {
|
|
if ($inTable) {
|
|
$docElements[] = make_table($tableRows);
|
|
$tableRows = [];
|
|
$inTable = false;
|
|
}
|
|
}
|
|
|
|
if ($trimmed === '') {
|
|
continue;
|
|
}
|
|
|
|
// Horizontal line
|
|
if (in_array($trimmed, ['---', '***', '___'], true)) {
|
|
$docElements[] = '<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="CBD5E1"/></w:pBdr></w:pPr></w:p>';
|
|
continue;
|
|
}
|
|
|
|
// Headings
|
|
if (str_starts_with($trimmed, '# ')) {
|
|
$runs = parse_inline_formatting(substr($trimmed, 2), defaultSize: 34, defaultColor: '1E3A8A');
|
|
$docElements[] = make_paragraph($runs, spaceBefore: 320, spaceAfter: 120);
|
|
} elseif (str_starts_with($trimmed, '## ')) {
|
|
$runs = parse_inline_formatting(substr($trimmed, 3), defaultSize: 26, defaultColor: '1E40AF');
|
|
$docElements[] = make_paragraph($runs, spaceBefore: 240, spaceAfter: 100);
|
|
} elseif (str_starts_with($trimmed, '### ')) {
|
|
$runs = parse_inline_formatting(substr($trimmed, 4), defaultSize: 22, defaultColor: '0F172A');
|
|
$docElements[] = make_paragraph($runs, spaceBefore: 180, spaceAfter: 80);
|
|
} elseif (str_starts_with($trimmed, '#### ')) {
|
|
$runs = parse_inline_formatting(substr($trimmed, 5), defaultSize: 20, defaultColor: '334155');
|
|
$docElements[] = make_paragraph($runs, spaceBefore: 140, spaceAfter: 60);
|
|
} elseif (str_starts_with($trimmed, '- ') || str_starts_with($trimmed, '* ')) {
|
|
$bullet = make_run("• ", bold: true, fontSize: 21, fontColor: '2563EB');
|
|
$textRuns = parse_inline_formatting(substr($trimmed, 2), defaultSize: 21, defaultColor: '334155');
|
|
$docElements[] = make_paragraph($bullet . $textRuns, spaceBefore: 40, spaceAfter: 40);
|
|
} elseif (preg_match('/^(\d+\.)\s+(.+)$/u', $trimmed, $matches)) {
|
|
$numRun = make_run($matches[1] . ' ', bold: true, fontSize: 21, fontColor: '1E40AF');
|
|
$textRuns = parse_inline_formatting($matches[2], defaultSize: 21, defaultColor: '334155');
|
|
$docElements[] = make_paragraph($numRun . $textRuns, spaceBefore: 40, spaceAfter: 40);
|
|
} elseif (str_starts_with($trimmed, '> ')) {
|
|
$quoteRuns = parse_inline_formatting(substr($trimmed, 2), defaultSize: 21, defaultColor: '475569');
|
|
$docElements[] = make_paragraph($quoteRuns, spaceBefore: 100, spaceAfter: 100, bgColor: 'F8FAFC');
|
|
} else {
|
|
$runs = parse_inline_formatting($trimmed, defaultSize: 21, defaultColor: '334155');
|
|
$docElements[] = make_paragraph($runs, spaceBefore: 60, spaceAfter: 60);
|
|
}
|
|
}
|
|
|
|
if ($inTable && !empty($tableRows)) {
|
|
$docElements[] = make_table($tableRows);
|
|
}
|
|
|
|
$documentXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
<w:body>' . implode('', $docElements) . '
|
|
<w:sectPr>
|
|
<w:pgSz w:w="12240" w:h="15840"/>
|
|
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
|
|
<w:cols w:space="720"/>
|
|
<w:docGrid w:linePitch="360"/>
|
|
</w:sectPr>
|
|
</w:body>
|
|
</w:document>';
|
|
|
|
$contentTypesXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
<Default Extension="xml" ContentType="application/xml"/>
|
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
|
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
|
|
</Types>';
|
|
|
|
$relsXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
</Relationships>';
|
|
|
|
$docRelsXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
|
|
</Relationships>';
|
|
|
|
$stylesXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
<w:docDefaults>
|
|
<w:rPrDefault>
|
|
<w:rPr>
|
|
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Calibri" w:cs="Calibri"/>
|
|
<w:sz w:val="22"/>
|
|
<w:szCs w:val="22"/>
|
|
<w:lang w:val="en-US"/>
|
|
</w:rPr>
|
|
</w:rPrDefault>
|
|
</w:docDefaults>
|
|
</w:styles>';
|
|
|
|
$settingsXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
<w:zoom w:percent="100"/>
|
|
</w:settings>';
|
|
|
|
if (file_exists($docxFile)) {
|
|
unlink($docxFile);
|
|
}
|
|
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($docxFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
|
fwrite(STDERR, "Error: Could not create zip archive: {$docxFile}\n");
|
|
exit(1);
|
|
}
|
|
|
|
$zip->addFromString('[Content_Types].xml', $contentTypesXml);
|
|
$zip->addFromString('_rels/.rels', $relsXml);
|
|
$zip->addFromString('word/_rels/document.xml.rels', $docRelsXml);
|
|
$zip->addFromString('word/styles.xml', $stylesXml);
|
|
$zip->addFromString('word/settings.xml', $settingsXml);
|
|
$zip->addFromString('word/document.xml', $documentXml);
|
|
$zip->close();
|
|
|
|
echo "Successfully generated Microsoft Word Document: {$docxFile}\n";
|
|
}
|
|
|
|
$mdFile = $argv[1] ?? 'docs/SYSTEM_SPECIFICATIONS_AND_LIFECYCLE_MANUAL.md';
|
|
$docxFile = $argv[2] ?? str_replace('.md', '.docx', $mdFile);
|
|
|
|
convert_markdown_to_docx($mdFile, $docxFile);
|