UNPKG

legal-markdown-js

Version:

Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version

304 lines 10.4 kB
import * as fs from 'fs/promises'; import * as path from 'path'; import { AlignmentType, Document, Footer, Header, ImageRun, PageNumber, PageOrientation, Packer, Paragraph, TextRun, } from 'docx'; import { logger } from '../../utils/logger.js'; import { htmlGenerator } from './html-generator.js'; import { PDF_TEMPLATE_CONSTANTS, RESOLVED_PATHS } from '../../constants/index.js'; import { adaptCssToDocxStyles } from './docx/css-style-adapter.js'; import { convertHtmlToDocxBlocks, createDefaultNumbering } from './docx/html-to-docx.js'; import { getCurrentDir } from '../../utils/esm-utils.js'; const MODULE_DIR = getCurrentDir(); const PAGE_SIZES = { A4: { width: 11906, height: 16838 }, Letter: { width: 12240, height: 15840 }, Legal: { width: 12240, height: 20160 }, }; function isPng(buffer) { return (buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47); } function toTwips(rawValue, fallbackTwips) { if (!rawValue) { return fallbackTwips; } const normalized = rawValue.trim().toLowerCase(); const number = Number.parseFloat(normalized.replace(/[a-z%]+/g, '')); if (!Number.isFinite(number)) { return fallbackTwips; } if (normalized.endsWith('cm')) { return Math.round(number * 566.929); } if (normalized.endsWith('mm')) { return Math.round(number * 56.6929); } if (normalized.endsWith('in')) { return Math.round(number * 1440); } if (normalized.endsWith('pt')) { return Math.round(number * 20); } if (normalized.endsWith('px')) { return Math.round(number * 15); } return Math.round(number); } async function readCssFile(cssPath) { if (!cssPath) { return ''; } try { return await fs.readFile(cssPath, 'utf8'); } catch (error) { logger.warn('Could not load CSS for DOCX generation', { cssPath, error: error instanceof Error ? error.message : String(error), }); return ''; } } async function resolveFirstReadablePath(candidates) { for (const candidate of candidates) { try { await fs.access(candidate); return candidate; } catch { // Continue to next candidate. } } return undefined; } function getDefaultCssCandidates() { return [ path.join(RESOLVED_PATHS.STYLES_DIR, 'default.css'), path.join(process.cwd(), 'src', 'styles', 'default.css'), path.resolve(MODULE_DIR, '..', '..', 'styles', 'default.css'), ]; } function getHighlightCssCandidates() { return [ path.join(RESOLVED_PATHS.STYLES_DIR, 'highlight.css'), path.join(process.cwd(), 'src', 'styles', 'highlight.css'), path.resolve(MODULE_DIR, '..', '..', 'styles', 'highlight.css'), ]; } function detectLogoFromCss(cssContent) { const match = cssContent.match(/--logo-filename:\s*([^;]+);/i); if (!match) { return null; } const value = match[1].trim().replace(/['"]/g, ''); return value || null; } async function loadLogoData(logoRef) { try { if (logoRef.startsWith('http://') || logoRef.startsWith('https://')) { const response = await fetch(logoRef); if (!response.ok) { return null; } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); if (!isPng(buffer) || buffer.length > PDF_TEMPLATE_CONSTANTS.MAX_LOGO_SIZE) { return null; } return buffer; } const logoPath = path.join(RESOLVED_PATHS.IMAGES_DIR, logoRef); const file = await fs.readFile(logoPath); if (!isPng(file) || file.length > PDF_TEMPLATE_CONSTANTS.MAX_LOGO_SIZE) { return null; } return file; } catch { return null; } } function buildPageSize(options) { const selectedFormat = options.format ?? 'A4'; const format = PAGE_SIZES[selectedFormat] ?? PAGE_SIZES.A4; if (options.landscape) { return { width: format.height, height: format.width, orientation: PageOrientation.LANDSCAPE, }; } return { width: format.width, height: format.height, orientation: PageOrientation.PORTRAIT, }; } function buildMargins(options) { const margin = options.margin; return { top: toTwips(margin?.top, 1440), right: toTwips(margin?.right, 1440), bottom: toTwips(margin?.bottom, 1440), left: toTwips(margin?.left, 1440), header: 720, footer: 720, gutter: 0, }; } function buildConversionOptions(stylePreset, includeHighlighting, basePath) { return { styles: stylePreset, includeHighlighting, basePath, }; } async function createHeader(logoData, options) { if (options.headerTemplate?.trim()) { const blocks = await convertHtmlToDocxBlocks(options.headerTemplate, buildConversionOptions(options.stylePreset, options.includeHighlighting, options.basePath)); return new Header({ children: blocks.length ? blocks : [new Paragraph({ children: [new TextRun('')] })], }); } if (!logoData) { return new Header({ children: [new Paragraph({ children: [new TextRun('')] })], }); } return new Header({ children: [ new Paragraph({ alignment: AlignmentType.RIGHT, children: [ new ImageRun({ data: logoData, type: 'png', transformation: { width: 120, height: 40, }, }), ], }), ], }); } async function createFooter(options) { if (options.footerTemplate?.trim()) { const blocks = await convertHtmlToDocxBlocks(options.footerTemplate, buildConversionOptions(options.stylePreset, options.includeHighlighting, options.basePath)); return new Footer({ children: blocks.length ? blocks : [new Paragraph({ children: [new TextRun('')] })], }); } const children = []; if (options.version) { children.push(new Paragraph({ alignment: AlignmentType.LEFT, children: [ new TextRun({ text: `v${options.version}`, size: 16, color: '666666', }), ], })); } children.push(new Paragraph({ alignment: AlignmentType.RIGHT, children: [ new TextRun('Pg: '), new TextRun({ children: [PageNumber.CURRENT] }), new TextRun(' / '), new TextRun({ children: [PageNumber.TOTAL_PAGES] }), ], })); return new Footer({ children }); } async function resolveCssBundle(options) { const baseCssPath = options.cssPath ?? (await resolveFirstReadablePath(getDefaultCssCandidates())) ?? path.join(RESOLVED_PATHS.STYLES_DIR, 'default.css'); const baseCss = await readCssFile(baseCssPath); let combined = baseCss; if (options.includeHighlighting) { const highlightPath = options.highlightCssPath ?? (await resolveFirstReadablePath(getHighlightCssCandidates())) ?? path.join(RESOLVED_PATHS.STYLES_DIR, 'highlight.css'); const highlightCss = await readCssFile(highlightPath); if (highlightCss) { combined += `\n${highlightCss}`; } } return { combined, baseCss, }; } export class DocxGenerator { async generateDocx(markdownContent, outputPath, options = {}) { const htmlContent = await htmlGenerator.generateHtml(markdownContent, options); return this.generateDocxFromHtml(htmlContent, outputPath, options); } async generateDocxFromHtml(htmlContent, outputPath, options = {}) { const outputDir = path.dirname(outputPath); await fs.mkdir(outputDir, { recursive: true }); const cssBundle = await resolveCssBundle(options); const stylePreset = adaptCssToDocxStyles(cssBundle.combined); const includeHighlighting = options.includeHighlighting ?? false; const basePath = options.basePath ?? path.dirname(outputPath); const logoRef = detectLogoFromCss(cssBundle.baseCss); const logoData = logoRef ? await loadLogoData(logoRef) : null; const blocks = await convertHtmlToDocxBlocks(htmlContent, { styles: stylePreset, includeHighlighting, basePath, }); const header = await createHeader(logoData, { headerTemplate: options.headerTemplate, stylePreset, includeHighlighting, basePath, }); const footer = await createFooter({ footerTemplate: options.footerTemplate, version: options.version, stylePreset, includeHighlighting, basePath, }); const document = new Document({ title: options.title, description: options.title, styles: stylePreset.styles, numbering: createDefaultNumbering(stylePreset), sections: [ { properties: { page: { size: buildPageSize(options), margin: buildMargins(options), }, }, headers: { default: header, }, footers: { default: footer, }, children: blocks, }, ], }); const buffer = await Packer.toBuffer(document); await fs.writeFile(outputPath, buffer); logger.info('DOCX generated successfully', { outputPath, size: buffer.length, }); return buffer; } } export const docxGenerator = new DocxGenerator(); //# sourceMappingURL=docx-generator.js.map