UNPKG

legal-markdown-js

Version:

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

856 lines 31.6 kB
import * as path from 'path'; import * as fs from 'fs/promises'; import * as cheerio from 'cheerio'; import { ImageRun } from 'docx'; import { AlignmentType, BorderStyle, ExternalHyperlink, HeadingLevel, LevelFormat, Paragraph, ShadingType, Table, TableCell, TableRow, TextRun, WidthType, } from 'docx'; const ORDERED_REFERENCES = { decimal: 'legalOrderedDecimal', lowerLetter: 'legalOrderedLowerLetter', upperLetter: 'legalOrderedUpperLetter', lowerRoman: 'legalOrderedLowerRoman', upperRoman: 'legalOrderedUpperRoman', }; const BULLET_REFERENCES = { disc: 'legalBulletDisc', circle: 'legalBulletCircle', square: 'legalBulletSquare', dash: 'legalBulletDash', }; function isElement(node) { return node.type === 'tag'; } function getTextNodeData(node) { const maybeText = node; if (maybeText.type !== 'text') { return null; } return typeof maybeText.data === 'string' ? maybeText.data : null; } function normalizeText(input, preserveWhitespace = false) { const normalized = input.replace(/\u00a0/g, ' '); if (preserveWhitespace) { return normalized; } return normalized.replace(/\r?\n+/g, ' ').replace(/\s+/g, ' '); } function getClassList(node) { return (node.attribs?.class ?? '') .split(/\s+/) .map(item => item.trim()) .filter(Boolean); } function parseStyleAttribute(styleAttribute) { const declarations = new Map(); if (!styleAttribute) { return declarations; } for (const declaration of styleAttribute.split(';')) { const separator = declaration.indexOf(':'); if (separator <= 0) { continue; } const property = declaration.slice(0, separator).trim().toLowerCase(); const value = declaration .slice(separator + 1) .replace(/\s*!important\s*$/i, '') .trim(); if (!property || !value) { continue; } declarations.set(property, value); } return declarations; } function getInlineStyleProperty(node, property) { return parseStyleAttribute(node.attribs?.style).get(property.toLowerCase()); } function parsePixelDimension(value, fallback) { if (!value) { return fallback; } const parsed = Number.parseFloat(value.replace('px', '').trim()); if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } return Math.round(parsed); } function isBreakDirective(value) { if (!value) { return false; } const normalized = value.trim().toLowerCase(); return (normalized === 'always' || normalized === 'page' || normalized === 'left' || normalized === 'right'); } function hasPageBreakBefore(node, classList) { if (classList.includes('page-break') || classList.includes('page-break-before')) { return true; } return (isBreakDirective(getInlineStyleProperty(node, 'page-break-before')) || isBreakDirective(getInlineStyleProperty(node, 'break-before'))); } function hasPageBreakAfter(node, classList) { if (classList.includes('page-break-after')) { return true; } return (isBreakDirective(getInlineStyleProperty(node, 'page-break-after')) || isBreakDirective(getInlineStyleProperty(node, 'break-after'))); } function resolveParagraphAlignment(node, classList) { if (classList.includes('text-center')) { return AlignmentType.CENTER; } const inlineAlignment = getInlineStyleProperty(node, 'text-align')?.toLowerCase(); if (!inlineAlignment) { return undefined; } if (inlineAlignment === 'center') return AlignmentType.CENTER; if (inlineAlignment === 'right' || inlineAlignment === 'end') return AlignmentType.RIGHT; if (inlineAlignment === 'justify') return AlignmentType.JUSTIFIED; return AlignmentType.LEFT; } function resolveRunFormattingFromClasses(classList, options) { const formatting = {}; if (classList.includes('text-muted')) { formatting.color = options.styles.colors.muted; } return formatting; } function resolveParagraphStyleId(classList, options, fallbackStyle) { if (classList.includes('confidential')) { return options.styles.styleIds.confidentialBanner; } if (classList.includes('separator')) { return options.styles.styleIds.separatorBanner; } if (classList.includes('algorithm')) { return options.styles.styleIds.algorithmBlock; } if (classList.includes('table-of-contents')) { return options.styles.styleIds.tocContainer; } return fallbackStyle; } function parseOrderedListStyle(rawValue) { if (!rawValue) { return undefined; } const value = rawValue.trim().toLowerCase(); if (value === 'decimal') return 'decimal'; if (value === 'lower-alpha' || value === 'lower-latin') return 'lowerLetter'; if (value === 'upper-alpha' || value === 'upper-latin') return 'upperLetter'; if (value === 'lower-roman') return 'lowerRoman'; if (value === 'upper-roman') return 'upperRoman'; return undefined; } function parseBulletListStyle(rawValue) { if (!rawValue) { return undefined; } const value = rawValue.trim().toLowerCase(); if (value === 'disc') return 'disc'; if (value === 'circle') return 'circle'; if (value === 'square') return 'square'; if (value === 'dash') return 'dash'; return undefined; } function suppressListMarkers(context, listStyleType, listNode, classList) { if (context.inTableOfContents || classList.includes('table-of-contents')) { return true; } if (listStyleType?.trim().toLowerCase() === 'none') { return true; } const liStyleType = getInlineStyleProperty(listNode, 'list-style')?.trim().toLowerCase(); if (liStyleType === 'none') { return true; } return false; } function createPageBreakParagraph() { return new Paragraph({ pageBreakBefore: true, children: [new TextRun('')], }); } function createTextRun(text, formatting) { return new TextRun({ text, bold: formatting.bold, italics: formatting.italics, strike: formatting.strike, underline: formatting.underline ? {} : undefined, style: formatting.style, color: formatting.color, }); } function placeholderAwareRuns(text, options, formatting) { if (!options.includeHighlighting || formatting.style) { return [createTextRun(text, formatting)]; } const matches = Array.from(text.matchAll(/\{\{[^}]+\}\}/g)); if (!matches.length) { return [createTextRun(text, formatting)]; } const runs = []; let cursor = 0; for (const match of matches) { const token = match[0]; const start = match.index ?? -1; if (start < 0) { continue; } if (start > cursor) { runs.push(createTextRun(text.slice(cursor, start), formatting)); } runs.push(createTextRun(token, { ...formatting, style: options.styles.styleIds.missingValue, })); cursor = start + token.length; } if (cursor < text.length) { runs.push(createTextRun(text.slice(cursor), formatting)); } return runs.length ? runs : [createTextRun(text, formatting)]; } async function loadImageData(source, basePath) { if (!source) { return undefined; } if (source.startsWith('data:image/')) { const commaIndex = source.indexOf(','); if (commaIndex < 0) { return undefined; } const encoded = source.slice(commaIndex + 1); return Buffer.from(encoded, 'base64'); } if (source.startsWith('http://') || source.startsWith('https://')) { const response = await fetch(source); if (!response.ok) { return undefined; } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } const resolved = path.isAbsolute(source) ? source : path.resolve(basePath ?? process.cwd(), source); try { return await fs.readFile(resolved); } catch { return undefined; } } function headingForTag(tagName) { switch (tagName) { case 'h1': return HeadingLevel.HEADING_1; case 'h2': return HeadingLevel.HEADING_2; case 'h3': return HeadingLevel.HEADING_3; case 'h4': return HeadingLevel.HEADING_4; case 'h5': return HeadingLevel.HEADING_5; case 'h6': return HeadingLevel.HEADING_6; default: return undefined; } } function getHighlightStyleId(classList, stylePreset, includeHighlighting) { if (!includeHighlighting) { return undefined; } if (classList.includes('imported-value')) { return stylePreset.styleIds.importedValue; } if (classList.includes('missing-value')) { return stylePreset.styleIds.missingValue; } if (classList.includes('highlight')) { return stylePreset.styleIds.logicHighlight; } return undefined; } async function runFromImage(node, basePath) { const attributes = node.attribs ?? {}; const src = attributes.src; if (!src) { return undefined; } const data = await loadImageData(src, basePath); if (!data) { return undefined; } const width = parsePixelDimension(attributes.width, 160); const height = parsePixelDimension(attributes.height, 48); const lowerSrc = src.toLowerCase(); const imageType = lowerSrc.includes('image/jpeg') || lowerSrc.endsWith('.jpg') || lowerSrc.endsWith('.jpeg') ? 'jpg' : lowerSrc.includes('image/gif') || lowerSrc.endsWith('.gif') ? 'gif' : lowerSrc.includes('image/bmp') || lowerSrc.endsWith('.bmp') ? 'bmp' : 'png'; return new ImageRun({ data, type: imageType, transformation: { width, height, }, }); } async function nodesToRuns($, nodes, options, formatting = {}) { const runs = []; for (const node of nodes) { const textData = getTextNodeData(node); if (textData !== null) { const text = normalizeText(textData, formatting.preserveWhitespace === true); if (!text) { continue; } runs.push(...placeholderAwareRuns(text, options, formatting)); continue; } if (!isElement(node)) { continue; } const tagName = node.name.toLowerCase(); const children = $(node).contents().toArray(); if (tagName === 'br') { runs.push(new TextRun({ break: 1 })); continue; } if (tagName === 'img') { const image = await runFromImage(node, options.basePath); if (image) { runs.push(image); } continue; } if (tagName === 'a') { const link = node.attribs?.href; const linkChildren = await nodesToRuns($, children, options, formatting); if (!linkChildren.length) { continue; } if (link && /^(https?:)?\/\//i.test(link)) { runs.push(new ExternalHyperlink({ link, children: linkChildren, })); } else { runs.push(...linkChildren); } continue; } const classList = getClassList(node); const nextFormatting = { ...formatting, ...resolveRunFormattingFromClasses(classList, options), }; if (tagName === 'strong' || tagName === 'b') { nextFormatting.bold = true; } if (tagName === 'em' || tagName === 'i') { nextFormatting.italics = true; } if (tagName === 's' || tagName === 'strike' || tagName === 'del') { nextFormatting.strike = true; } if (tagName === 'u') { nextFormatting.underline = true; } if (tagName === 'code') { nextFormatting.style = options.styles.styleIds.inlineCode; } const highlightStyle = getHighlightStyleId(classList, options.styles, options.includeHighlighting); if (highlightStyle) { nextFormatting.style = highlightStyle; } const childRuns = await nodesToRuns($, children, options, nextFormatting); runs.push(...childRuns); } return runs; } async function elementToParagraph($, node, options, fallbackStyle, runFormatting = {}) { const classList = getClassList(node); const style = resolveParagraphStyleId(classList, options, fallbackStyle); const alignment = resolveParagraphAlignment(node, classList); const classRunFormatting = resolveRunFormattingFromClasses(classList, options); const children = await nodesToRuns($, $(node).contents().toArray(), options, { ...runFormatting, ...classRunFormatting, }); return new Paragraph({ style, ...(alignment ? { alignment } : {}), ...(hasPageBreakBefore(node, classList) ? { pageBreakBefore: true } : {}), children: children.length ? children : [new TextRun('')], }); } async function listToParagraphs($, listNode, options, ordered, level, context) { const paragraphs = []; const listClassList = getClassList(listNode); const inlineListStyle = getInlineStyleProperty(listNode, 'list-style-type'); const hideMarkers = suppressListMarkers(context, inlineListStyle, listNode, listClassList); const orderedStyle = parseOrderedListStyle(inlineListStyle) ?? options.styles.lists.ordered; const bulletStyle = parseBulletListStyle(inlineListStyle) ?? options.styles.lists.bullet; const numberingReference = hideMarkers ? undefined : ordered ? ORDERED_REFERENCES[orderedStyle] : BULLET_REFERENCES[bulletStyle]; const liElements = $(listNode).children('li').toArray().filter(isElement); for (const li of liElements) { const allChildren = $(li).contents().toArray(); const inlineNodes = allChildren.filter(node => { if (!isElement(node)) { return true; } const tag = node.name.toLowerCase(); return tag !== 'ul' && tag !== 'ol'; }); const liClassList = getClassList(li); const runs = await nodesToRuns($, inlineNodes, options, resolveRunFormattingFromClasses(liClassList, options)); const alignment = resolveParagraphAlignment(li, liClassList); const liListStyle = getInlineStyleProperty(li, 'list-style-type'); const liHideMarker = liListStyle?.trim().toLowerCase() === 'none'; paragraphs.push(new Paragraph({ style: options.styles.styleIds.body, ...(alignment ? { alignment } : {}), children: runs.length ? runs : [new TextRun('')], ...(!liHideMarker && numberingReference ? { numbering: { reference: numberingReference, level: Math.min(level, 8), }, } : {}), })); const nestedLists = allChildren.filter((node) => isElement(node) && (node.name.toLowerCase() === 'ul' || node.name.toLowerCase() === 'ol')); for (const nested of nestedLists) { const nestedOrdered = nested.name.toLowerCase() === 'ol'; const nestedParagraphs = await listToParagraphs($, nested, options, nestedOrdered, level + 1, context); paragraphs.push(...nestedParagraphs); } } return paragraphs; } async function tableFromElement($, tableNode, options, context) { const classList = getClassList(tableNode); const isSignaturesTable = classList.includes('signatures'); const rowElements = $(tableNode).find('tr').toArray().filter(isElement); const rows = []; for (const rowElement of rowElements) { const cellElements = $(rowElement).children('th,td').toArray().filter(isElement); if (!cellElements.length) { continue; } if (isSignaturesTable && cellElements.every(cell => cell.name.toLowerCase() === 'th')) { continue; } const cells = []; for (const [cellIndex, cellElement] of cellElements.entries()) { const tag = cellElement.name.toLowerCase(); const blocks = await nodesToBlocks($, $(cellElement).contents().toArray(), options, context); const blockChildren = blocks.length ? blocks : [new Paragraph({ style: options.styles.styleIds.body, children: [new TextRun('')] })]; const signatureBottomBorder = { style: BorderStyle.SINGLE, color: options.styles.colors.primary, size: 10, }; const defaultBorders = { top: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, bottom: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, left: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, right: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, }; cells.push(new TableCell({ children: blockChildren, ...(isSignaturesTable ? {} : { shading: tag === 'th' ? { fill: options.styles.table.headerFill, type: ShadingType.CLEAR, color: options.styles.table.headerTextColor, } : { fill: options.styles.table.cellFill, type: ShadingType.CLEAR, color: options.styles.table.headerTextColor, }, }), margins: { top: 80, bottom: 80, left: 120, right: 120, }, ...(isSignaturesTable ? { width: { size: cellIndex === 0 ? 33 : 67, type: WidthType.PERCENTAGE, }, borders: { top: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, bottom: signatureBottomBorder, left: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, right: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, }, } : { borders: defaultBorders }), })); } rows.push(new TableRow({ children: cells })); } return new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE, }, borders: isSignaturesTable ? { top: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, bottom: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, left: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, right: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, insideHorizontal: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, insideVertical: { style: BorderStyle.NONE, color: 'FFFFFF', size: 0, }, } : { top: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, bottom: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, left: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, right: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, insideHorizontal: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, insideVertical: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 4, }, }, }); } function appendPageBreakAfterIfNeeded(blocks, node, classList) { if (hasPageBreakAfter(node, classList)) { blocks.push(createPageBreakParagraph()); } } export async function nodesToBlocks($, nodes, options, context = { inTableOfContents: false }) { const blocks = []; for (const node of nodes) { const textData = getTextNodeData(node); if (textData !== null) { const text = normalizeText(textData).trim(); if (!text) { continue; } blocks.push(new Paragraph({ style: options.styles.styleIds.body, children: placeholderAwareRuns(text, options, {}), })); continue; } if (!isElement(node)) { continue; } const tagName = node.name.toLowerCase(); const classList = getClassList(node); if (tagName === 'ul' || tagName === 'ol') { if (hasPageBreakBefore(node, classList)) { blocks.push(createPageBreakParagraph()); } const paragraphs = await listToParagraphs($, node, options, tagName === 'ol', 0, context); blocks.push(...paragraphs); appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } if (tagName === 'table') { if (hasPageBreakBefore(node, classList)) { blocks.push(createPageBreakParagraph()); } blocks.push(await tableFromElement($, node, options, context)); appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } if (tagName === 'hr') { blocks.push(new Paragraph({ style: resolveParagraphStyleId(classList, options, options.styles.styleIds.body), border: { top: { style: BorderStyle.SINGLE, color: options.styles.table.borderColor, size: 6, }, }, spacing: { before: 200, after: 200, }, ...(hasPageBreakBefore(node, classList) ? { pageBreakBefore: true } : {}), })); appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } if (tagName === 'pre') { const fallbackStyle = classList.includes('algorithm') ? options.styles.styleIds.algorithmBlock : options.styles.styleIds.codeBlock; blocks.push(await elementToParagraph($, node, options, fallbackStyle, { preserveWhitespace: true, })); appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } if (tagName === 'blockquote') { blocks.push(await elementToParagraph($, node, options, options.styles.styleIds.blockQuote)); appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } if (tagName === 'div') { const nextContext = { inTableOfContents: context.inTableOfContents || classList.includes('table-of-contents'), }; const renderAsSingleParagraph = classList.includes('confidential') || classList.includes('separator') || classList.includes('algorithm'); if (!renderAsSingleParagraph && hasPageBreakBefore(node, classList)) { blocks.push(createPageBreakParagraph()); } if (renderAsSingleParagraph) { blocks.push(await elementToParagraph($, node, options, options.styles.styleIds.body)); } else { const nested = await nodesToBlocks($, $(node).contents().toArray(), options, nextContext); blocks.push(...nested); } appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } if (tagName === 'img') { const image = await runFromImage(node, options.basePath); if (image) { blocks.push(new Paragraph({ alignment: resolveParagraphAlignment(node, classList) ?? AlignmentType.CENTER, ...(hasPageBreakBefore(node, classList) ? { pageBreakBefore: true } : {}), children: [image], })); } appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } const headingLevel = headingForTag(tagName); if (headingLevel) { const headingAlignment = resolveParagraphAlignment(node, classList); const headingChildren = await nodesToRuns($, $(node).contents().toArray(), options, resolveRunFormattingFromClasses(classList, options)); blocks.push(new Paragraph({ heading: headingLevel, style: `heading${tagName.slice(1)}Legal`, ...(headingAlignment ? { alignment: headingAlignment } : {}), ...(hasPageBreakBefore(node, classList) ? { pageBreakBefore: true } : {}), children: headingChildren.length ? headingChildren : [new TextRun($(node).text())], })); appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } if (tagName === 'p' || tagName === 'section' || tagName === 'article') { blocks.push(await elementToParagraph($, node, options, options.styles.styleIds.body)); appendPageBreakAfterIfNeeded(blocks, node, classList); continue; } if (hasPageBreakBefore(node, classList)) { blocks.push(createPageBreakParagraph()); } const nextContext = { inTableOfContents: context.inTableOfContents || classList.includes('table-of-contents'), }; const fallbackBlocks = await nodesToBlocks($, $(node).contents().toArray(), options, nextContext); blocks.push(...fallbackBlocks); appendPageBreakAfterIfNeeded(blocks, node, classList); } return blocks; } export async function convertHtmlToDocxBlocks(htmlContent, options) { const $ = cheerio.load(htmlContent); const rootNodes = $('body').length > 0 ? $('body').contents().toArray() : $.root().contents().toArray(); return nodesToBlocks($, rootNodes, options); } function orderedLevelSequence(style) { switch (style) { case 'lowerLetter': return [LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN, LevelFormat.DECIMAL]; case 'upperLetter': return [LevelFormat.UPPER_LETTER, LevelFormat.UPPER_ROMAN, LevelFormat.DECIMAL]; case 'lowerRoman': return [LevelFormat.LOWER_ROMAN, LevelFormat.LOWER_LETTER, LevelFormat.DECIMAL]; case 'upperRoman': return [LevelFormat.UPPER_ROMAN, LevelFormat.UPPER_LETTER, LevelFormat.DECIMAL]; case 'decimal': default: return [LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN]; } } function bulletSequence(style) { switch (style) { case 'circle': return ['◦', '•', '▪']; case 'square': return ['▪', '•', '◦']; case 'dash': return ['-', '•', '◦']; case 'disc': default: return ['•', '◦', '▪']; } } export function createDefaultNumbering(styles) { const orderedStyles = Object.keys(ORDERED_REFERENCES); const bulletStyles = Object.keys(BULLET_REFERENCES); if (styles?.lists.ordered) { orderedStyles.sort((left, right) => { if (left === styles.lists.ordered) return -1; if (right === styles.lists.ordered) return 1; return 0; }); } if (styles?.lists.bullet) { bulletStyles.sort((left, right) => { if (left === styles.lists.bullet) return -1; if (right === styles.lists.bullet) return 1; return 0; }); } const orderedConfigs = orderedStyles.map(style => { const sequence = orderedLevelSequence(style); return { reference: ORDERED_REFERENCES[style], levels: Array.from({ length: 9 }, (_entry, level) => ({ level, format: sequence[level % sequence.length], text: `%${level + 1}.`, alignment: AlignmentType.LEFT, })), }; }); const bulletConfigs = bulletStyles.map(style => { const sequence = bulletSequence(style); return { reference: BULLET_REFERENCES[style], levels: Array.from({ length: 9 }, (_entry, level) => ({ level, format: LevelFormat.BULLET, text: sequence[level % sequence.length], alignment: AlignmentType.LEFT, })), }; }); return { config: [...orderedConfigs, ...bulletConfigs], }; } //# sourceMappingURL=html-to-docx.js.map