UNPKG

@adobe/mdast-util-gridtables

Version:
641 lines (583 loc) 18.9 kB
/* * Copyright 2022 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ /* eslint-disable no-unused-vars,no-param-reassign */ import { defaultHandlers } from 'mdast-util-to-markdown'; import { TYPE_BODY, TYPE_CELL, TYPE_HEADER, TYPE_FOOTER, TYPE_ROW, TYPE_TABLE, } from './types.js'; import sanitizeBreaks, { PHRASING_TYPES } from './mdast-clean-breaks.js'; const { text: textHandler, inlineCode, code, } = defaultHandlers; function* distribute(size, times) { const delta = size / times; let len = delta; let prevLen = 0; for (let i = 0; i < times - 1; i += 1) { yield [Math.round(len - prevLen), i]; prevLen = Math.round(len); len += delta; } yield [Math.round(size - prevLen), times - 1]; } function spanWidth(cols, idx, cell) { let width = 0; for (let i = 0; i < cell.colSpan; i += 1) { width += cols[idx + i].width; } return width; } export function lineWrapTextHandler(node, parent, context, safeOptions) { const textNode = { ...node, value: node.value.replace(/[ \t\v\r\n]/g, ' '), }; let value = textHandler(textNode, parent, context, safeOptions); const { lineWidth } = context.options; if (lineWidth && value.length > lineWidth) { // check if in heading if (context.stack.includes('headingAtx')) { return value; } const lines = []; const words = value.split(' '); let len = safeOptions.now.column - 1; let line = []; for (let word of words) { const wordLen = word.length; if (len + wordLen > lineWidth && line.length > 0) { lines.push(line.join(' ')); line = []; len = 0; } // escape the dot if the line-start looks like an ordered list if (line.length === 0 && word.match(/^\d+\./)) { word = word.replace('.', '\\.'); } // escape the hyphen if the line-start looks like a list if (line.length === 0 && word === '-') { word = '\\-'; } // escape the greater symbol if a wrapped line start looks like a blockquote if (lines.length && line.length === 0 && word.startsWith('>')) { word = `\\>${word.substring(1)}`; } line.push(word); len += wordLen + 1; } if (line.length) { lines.push(line.join(' ')); } value = lines.join('\n'); } return value; } // don't wrap for peek operations lineWrapTextHandler.peek = textHandler; class Table { constructor() { Object.assign(this, { lastRow: null, rows: [], headerSize: 0, footerSize: 0, opts: { // default desired width of a table (including delimiters) width: 120, // minimum cell content width (excluding delimiters) minCellWidth: 12, }, }); } addHeaderRow(row) { this.addRow(row, this.headerSize); this.headerSize += 1; } addRow(cells, idx = this.rows.length - this.footerSize) { const row = { height: 0, cells: [], }; this.rows.splice(idx, 0, row); this.lastRow = this.rows[this.rows.length - 1]; for (const cell of cells) { this.addCell(cell, row); } } addFooterRow(row) { this.addRow(row, this.rows.length); this.footerSize += 1; } addCell(cell, row) { if (!this.lastRow) { this.lastRow = { height: 0, cells: [], }; this.rows.push(this.lastRow); } row = row || this.lastRow; row.cells.push(cell); for (let i = 1; i < cell.colSpan; i += 1) { row.cells.push({ align: cell.align, }); } } renderCell(cell, state, maxWidth) { // set line wrap to width const oldWidth = state.options.lineWidth; // it's easier to calculate in the padding (+2) and border (+1) here than everywhere else. // so the column width is equal to the cell.width state.options.lineWidth = maxWidth - 3; state.options.minLineWidth = this.opts.minCellWidth; // enter cell construct in order to escape unsafe characters const exit = state.enter(TYPE_CELL); const subexit = state.enter('phrasing'); // should probably create a clone and not alter the original mdast sanitizeBreaks(cell.tree); // if the cell only contains phrasing nodes, wrap with a paragraph if (cell.tree.children.every((n) => PHRASING_TYPES[n.type])) { cell.tree.children = [{ type: 'paragraph', children: cell.tree.children, }]; } const lines = state.containerFlow(cell.tree, { before: '\n', after: '\n', now: { line: 1, column: 1 }, lineShift: 0, }).split('\n'); // reset bullet state delete state.bulletLastUsed; subexit(); exit(); state.options.lineWidth = oldWidth; cell.lines = lines; cell.height = lines.length; cell.width = 0; // calculate actual width and height of cell and transform the tab stops correctly const TABS = [ ' ', ' ', ' ', ' ', ]; for (let i = 0; i < lines.length; i += 1) { let line = lines[i]; let idx = line.indexOf('\t'); if (idx >= 0) { do { // adjust tabstops line = line.substring(0, idx) + TABS[idx % 4] + line.substring(idx + 1); idx = line.indexOf('\t', idx + 1); } while (idx >= 0); lines[i] = line; } cell.width = Math.max(cell.width, line.length); } cell.value = lines.join('\n'); cell.width += 3; return cell; } toMarkdown(context) { // populate the matrix with the rowspans and compute max width // (the empty cells for the colspans are already created during insert). let realNumCols = 0; const cols = []; for (let y = 0; y < this.rows.length; y += 1) { const row = this.rows[y]; for (let x = 0; x < row.cells.length; x += 1) { let col = cols[x]; if (!col) { col = { width: 3, }; cols[x] = col; } const cell = row.cells[x]; if (cell.tree) { realNumCols = Math.max(realNumCols, x + 1); } // ensure rowspan is not too large cell.rowSpan = Math.min(cell.rowSpan, this.rows.length - y); if (cell.rowSpan > 1) { // insert colspan amount of null cells below for (let i = 1; i < cell.rowSpan; i += 1) { const yy = i + y; // create empty linked cells for the rows, so that it can render the lines correctly. const empty = new Array(cell.colSpan).fill({}); empty[0] = { linked: cell }; this.rows[yy].cells.splice(x, 0, ...empty); } } } } // now trim tailing colspans if (cols.length > realNumCols) { cols.length = realNumCols; for (const { cells } of this.rows) { if (cells.length > realNumCols) { cells.length = realNumCols; // find trailing colspan let x = cells.length - 1; while (x >= 0 && !cells[x].tree) { x -= 1; } if (x >= 0) { cells[x].colSpan = realNumCols - x; } } } } // stop processing if no columns found if (cols.length === 0) { return ''; } const numCols = cols.length; // add empty cells if needed for (const row of this.rows) { for (let i = row.cells.length; i < numCols; i += 1) { row.cells.push({ tree: { type: 'root', children: [] }, colSpan: 1, rowSpan: 1 }); } } // populate the columns with default max widths for (const [d, idx] of distribute(this.opts.width, numCols)) { cols[idx].maxWidth = d; } // render cells for (const row of this.rows) { for (let x = 0; x < row.cells.length; x += 1) { const cell = row.cells[x]; if (cell.tree) { // get the max width from the columns it spans let maxWidth = 0; for (let i = 0; i < cell.colSpan; i += 1) { maxWidth += cols[x + i].maxWidth; } this.renderCell(cell, context, maxWidth); // distribute effective cell.width among the columns it spans for (const [avgColWidth, idx] of distribute(cell.width, cell.colSpan)) { const col = cols[x + idx]; col.width = Math.max(col.width, avgColWidth); } // if valign, the col needs to be at least 4 (3 + delim) wide if (cell.valign) { cols[x].width = Math.max(4, cols[x].width); } } } } // re-render cells where elements dictated the min-width (eg, large headings) for (const row of this.rows) { row.minHeight = 0; row.height = 0; for (let x = 0; x < row.cells.length; x += 1) { const cell = row.cells[x]; if (cell.tree) { // get the max width from the columns it spans const width = spanWidth(cols, x, cell); if (width >= cell.width) { this.renderCell(cell, context, width); // if the new cell width is bigger now (most probably due to a problem in the line // break renderer), fix the columns. if (cell.width > width) { for (const [avgColWidth, idx] of distribute(cell.width, cell.colSpan)) { const col = cols[x + idx]; col.width = Math.max(col.width, avgColWidth); } } else { cell.width = width; } } if (cell.rowSpan === 1) { row.height = Math.max(row.height, cell.height); } } } } // distribute row spans for (let y = 0; y < this.rows.length; y += 1) { const row = this.rows[y]; for (let x = 0; x < row.cells.length; x += 1) { const cell = row.cells[x]; if (cell.rowSpan > 1) { const distHeight = Math.max(cell.rowSpan, cell.height - cell.rowSpan + 1); for (const [d, idx] of distribute(distHeight, cell.rowSpan)) { this.rows[y + idx].height = Math.max(this.rows[y + idx].height, d); } } } } // create grid and table const gtVLineEnds = '+'; const gtHLineEnds = '+'; const align = { left: { b: ':', e: '', len: 1 }, right: { b: '', e: ':', len: 1 }, center: { b: ':', e: ':', len: 2 }, justify: { b: '>', e: '<', len: 2 }, top: '^', bottom: 'v', middle: 'x', }; const lines = []; // eslint-disable-next-line no-nested-ternary const headerIdx = this.headerSize ? this.headerSize : (this.footerSize ? 0 : -1); const footerIdx = this.rows.length - this.footerSize; for (let y = 0; y < this.rows.length; y += 1) { const row = this.rows[y]; // first, draw the grid line const grid = []; const c = y === headerIdx || y === footerIdx ? '=' : '-'; let prevCell; let pendingGrid = 0; let pendingAlign = null; let pendingVAlign = null; const commitInnerGridLine = () => { if (pendingVAlign) { const middle = Math.floor((pendingGrid - 1) / 2); grid.push(c.repeat(middle)); grid.push(pendingVAlign); grid.push(c.repeat(pendingGrid - middle - 1)); } else { grid.push(c.repeat(pendingGrid)); } }; const commitGridLine = () => { if (pendingGrid) { if (pendingAlign) { pendingGrid -= pendingAlign.len; grid.push(pendingAlign.b); commitInnerGridLine(); grid.push(pendingAlign.e); } else { commitInnerGridLine(); } pendingGrid = 0; } }; for (let x = 0; x < row.cells.length; x += 1) { let d0 = '+'; if (x === 0 && y > 0) { d0 = gtHLineEnds; } if (y === 0 && x > 0) { d0 = gtVLineEnds; } const cell = row.cells[x]; const col = cols[x]; if (cell.tree) { commitGridLine(); grid.push(d0); pendingGrid = col.width - 1; pendingAlign = align[cell.align]; pendingVAlign = align[cell.valign]; } else if (cell.linked) { commitGridLine(); const width = spanWidth(cols, x, cell.linked); const text = cell.linked.lines.shift() || ''; grid.push(`| ${text.padEnd(width - 3, ' ')} `); x += cell.linked.colSpan - 1; } else { pendingGrid += col.width; } prevCell = cell; } commitGridLine(); // if last col was a rowspan, draw a | let d3 = prevCell?.linked ? '|' : gtHLineEnds; if (y === 0) { d3 = '+'; } lines.push(`${grid.join('')}${d3}`); // then draw the cells for (let yy = 0; yy < row.height; yy += 1) { const line = []; for (let x = 0; x < row.cells.length; x += 1) { let cell = row.cells[x]; if (cell.linked) { cell = cell.linked; } if (cell.tree) { const width = spanWidth(cols, x, cell); let text = ''; if (!cell.valign || cell.valign === 'top' || (cell.valign === 'middle' && yy >= Math.floor(row.height - cell.height) / 2) || (cell.valign === 'bottom' && yy >= row.height - cell.height)) { text = cell.lines.shift() || ''; } line.push(`| ${text.padEnd(width - 3, ' ')} `); } } lines.push(`${line.join('')}|`); } } // add last grid line const d = this.rows.length === this.headerSize ? '=' : '-'; // special case: only header const grid = []; const lastRow = this.rows[this.rows.length - 1]; for (let x = 0; x < cols.length; x += 1) { const col = cols[x]; // if the cell above was a colspan, and we are on the last line, don't draw the `+` const aboveCell = lastRow.cells[x]; let c = aboveCell.tree || aboveCell.linked ? gtVLineEnds : d; if (x === 0) { c = '+'; } grid.push(`${c}${d.repeat(col.width - 1)}`); } lines.push(`${grid.join('')}+`); return lines.join('\n'); } } function pushTable(context, table) { if (!context.gridTables) { context.gridTables = []; } context.gridTables.push(table); return table; } function popTable(context) { return context.gridTables.pop(); } function peekTable(context) { return context.gridTables[context.gridTables.length - 1]; } function handleCell(node, parent, context, safeOptions) { return { tree: { type: 'root', children: node.children, }, colSpan: node.colSpan || 1, rowSpan: node.rowSpan || 1, align: node.align, valign: node.valign, }; } function handleRow(node, parent, context, safeOptions) { const row = []; for (const child of node.children) { if (child.type === TYPE_CELL) { row.push(handleCell(child, node, context, safeOptions)); } } return row; } function handleHeader(node, parent, context, safeOptions) { const table = peekTable(context); for (const child of node.children) { if (child.type === TYPE_ROW) { table.addHeaderRow(handleRow(child, node, context, safeOptions)); } } } function handleBody(node, parent, context, safeOptions) { const table = peekTable(context); for (const child of node.children) { if (child.type === TYPE_ROW) { table.addRow(handleRow(child, node, context, safeOptions)); } } } function handleFooter(node, parent, context, safeOptions) { const table = peekTable(context); for (const child of node.children) { if (child.type === TYPE_ROW) { table.addFooterRow(handleRow(child, node, context, safeOptions)); } } } function gridTable(node, parent, context, safeOptions) { const exit = context.enter(TYPE_TABLE); const table = pushTable(context, new Table()); for (const child of node.children) { if (child.type === TYPE_HEADER) { handleHeader(child, node, context, safeOptions); } else if (child.type === TYPE_BODY) { handleBody(child, node, context, safeOptions); } else if (child.type === TYPE_FOOTER) { handleFooter(child, node, context, safeOptions); } else if (child.type === TYPE_ROW) { table.addRow(handleRow(child, node, context, safeOptions)); } else if (child.type === TYPE_CELL) { table.addCell(handleCell(child, node, context, safeOptions)); } } exit(); return popTable(context).toMarkdown(context); } /** * Escapes cell delimiters in (block)) code */ function blockCodeWithTable(node, parent, context) { let value = code(node, parent, context); if (context.stack.includes(TYPE_CELL)) { value = value.replace(/[|+]/mg, '\\$&'); // break code if wider than lineWidth or 256 chars, but at least 150 const lineWidth = Math.min(256, Math.max(150, context.options.lineWidth)); if (value.length > lineWidth) { // iterate over lines and break if needed const lines = []; for (let line of value.split('\n')) { while (line.length > lineWidth) { // avoid splitting escaped characters let len = lineWidth; if (line[len - 1] === '\\') { len -= 1; } lines.push(`${line.substring(0, len)}\u0083`); line = line.substring(len); } lines.push(line); } value = lines.join('\n'); } } return value; } // don't wrap for peek operations blockCodeWithTable.peek = code; /** * Escapes cell delimiters in inline code */ function inlineCodeWithTable(node, parent, context) { let value = inlineCode(node, parent, context); if (context.stack.includes(TYPE_CELL)) { value = value.replace(/[|+]/g, '\\$&'); } return value; } export function gridTablesToMarkdown() { return { unsafe: [ // A pipe or a + in a cell must be encoded. { character: '|', inConstruct: TYPE_CELL }, { character: '+', inConstruct: TYPE_CELL }, ], handlers: { // for now, we only line wrap 'text' nodes. all other would need more support in // the default mdast-to-markdown handlers text: lineWrapTextHandler, gridTable, inlineCode: inlineCodeWithTable, code: blockCodeWithTable, }, }; }