UNPKG

read-excel-file

Version:

Read `.xlsx` files in a web browser or in Node.js

555 lines (539 loc) 25.5 kB
function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } } function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } function _createForOfIteratorHelperLoose(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (it) return (it = it.call(o)).next.bind(it); if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; return function () { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; } import parseCell, { EMPTY_CELL } from './parseCell.js'; import parseCellAddress from './parseCellAddress.js'; import InvalidSpreadsheetError from './InvalidSpreadsheetError.js'; // An empty cell has `null` value. var EMPTY_CELL_VALUE = null; /** * Parses a `sheet.xml` file. * @param {string} content * @param {function} parseXml — SAX XML parser. * @param {object} options * @returns {Promise<SheetData>} */ export default function parseSheet(content, parseXml, _ref) { var sharedStrings = _ref.sharedStrings, styles = _ref.styles, epoch1904 = _ref.epoch1904, dateFormatDetectionCache = _ref.dateFormatDetectionCache, options = _ref.options; var parseCellParameters = [sharedStrings, styles, epoch1904, dateFormatDetectionCache, options.dateFormat, // defaultDateFormat options.smartDateParser !== false, // dateTemplateParser options.parseNumber // parseNumberCustom ]; var rows = []; var errors = []; var state = createInitialState(); return parseXml(content, state, onOpenTag, onCloseTag, onText, onProgress).then(function () { var _state$sheetData = state.sheetData, rowCount = _state$sheetData.rowCount, columnCount = _state$sheetData.columnCount, dataRowCount = _state$sheetData.dataRowCount, dataColumnCount = _state$sheetData.dataColumnCount; // Drop (discard) empty rows at the bottom. if (dataRowCount < rowCount) { rows = rows.slice(0, dataRowCount); } // Drop (discard) empty columns at the right side. if (dataColumnCount < columnCount) { var i = 0; while (i < rows.length) { if (rows[i].length > dataColumnCount) { rows[i] = rows[i].slice(0, dataColumnCount); } i++; } } var startedAt = Date.now(); // Add `null` values where there're "gaps" at the right side of rows. for (var _iterator = _createForOfIteratorHelperLoose(rows), _step; !(_step = _iterator()).done;) { var row = _step.value; while (row.length < dataColumnCount) { row.push(EMPTY_CELL_VALUE); } } return rows; }); function createInitialState() { return { dimension: undefined, sheetData: undefined }; } function getRowsFromState(state) { return state.sheetData.rows; } function setRowsInState(state, rows) { state.sheetData.rowIndexShift += state.sheetData.rows.length - rows.length; state.sheetData.rows = rows; } function getErrorsFromState(state) { return state.sheetData.errors; } function setErrorsInState(state, errors) { state.sheetData.errors = errors; } // Throw an error as soon as a non-recoverable error is encountered when parsing a cell. var THROW_ON_FIRST_CELL_ERROR = true; function throwInvalidCellError(_ref2) { var row = _ref2.row, column = _ref2.column, error = _ref2.error; // The sheet name or index is unknown, so it's not reported here. throw new InvalidSpreadsheetError("<c/> at row ".concat(row, ", col ").concat(column, ": ").concat(error)); } function onProgress(end) { // Here, it could look at `state.dimension?` in order to avoid reading unused cells. // For example, there could be a sheet with a million rows and a million columns // but only the top-left cell in that sheet would be not an empty cell. // The rest of the cells could be empty, but it would be valid for such sheet to exist. // So there would be empty `<row/>` or `<c/>` elements all around, and those could be skipped // if `<dimension/>` element is present in the `sheet.xml` file. // The `<dimension/>` element tells which area of the sheet really contains non-empty data. // But it's an optional element, and also there's an additional question of: // "What should happen if an .xlsx writer sets it erroneously? Should it auto-repair such sheet?". // I imagine, such situations aren't really possible in real life but still mathematically it's possible, // because the `<dimension/>` element is more of a "supplementary" or "advisory" one // and it's not supposed to "break" anything in case its value is incorrect. // So that's why `read-excel-file` just simply ignores the `<dimension/>` element // even though it can read and "understand" it. // console.log(state.dimension) var rowsRead = getRowsFromState(state); var errorsEncountered = getErrorsFromState(state); if (end) { rows = rows.concat(rowsRead); errors = errors.concat(errorsEncountered); if (errors.length > 0) { throwInvalidCellError(errors[0]); } } else { if (rowsRead.length > 1) { // The rows except for the last one have already been "finalized", // i.e. it can be said that those rows don't have any more cells yet to be parsed. // The last row is still being read so it's not "finalized" yet, // i.e. it's unknown at this point whether there will be more cells of it. var finalizedRows = rowsRead.slice(0, -1); // Move the finalized rows from the "in-progress" rows to the "result" rows. rows = rows.concat(finalizedRows); // Add any errors that have been encountered while parsing this chunk of rows. errors = errors.concat(errorsEncountered); // Proceeed with just the last row and the next chunk. setRowsInState(state, rowsRead.slice(-1)); // Proceeed with clean errors list. setErrorsInState(state, []); } } } function onOpenTag(tagName, attributes, state) { if (tagName === 'dimension') { state.dimension = parseSheetDimensionRef(attributes.ref); } else if (tagName === 'sheetData') { state.sheetData = createInitialStateInSheetData(); } else if (state.sheetData) { onOpenTagInSheetData(tagName, attributes, state.sheetData); } } function onCloseTag(tagName, state) { if (state.sheetData) { onCloseTagInSheetData(tagName, state.sheetData); } } function onText(text, state) { if (state.sheetData) { onTextInSheetData(text, state.sheetData); } } /** * Sheet "dimension" defines the spreadsheet area containing all non-empty cells. * Any cells outside the "dimension" are considered empty and should be ignored. * https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.sheetdimension?view=openxml-2.8.1 * @param {string} `ref` — The value of `<dimension ref/>` attribute. * @returns {[[number,number],[number,number]]} `undefined` or `[{ row, column }, { row, column }]` — "From row number and column number to row number and column number". */ function parseSheetDimensionRef(ref) { var dimensions = ref.split(':').map(parseCellAddress); // Sometimes there can be just a single cell as a spreadsheet's "dimensions". // For example, the default "dimensions" in Apache POI library is "A1", // meaning that only the first cell in the spreadsheet is used. // // A quote from Apache POI library: // "Single cell ranges are formatted like single cell references (e.g. 'A1' instead of 'A1:A1')." // if (dimensions.length === 1) { dimensions = [dimensions[0], dimensions[0]]; } return dimensions; } // Sheet Data function createInitialStateInSheetData() { return { c: undefined, rows: [], row: undefined, rowNumber: undefined, // How many rows have been removed from the start of `state.rows` // as part of `onProgress()` handler calls. rowIndexShift: 0, // Current position in the sheet. cursor: [0, 0], // Total row count. rowCount: 0, // Total column count. columnCount: 0, // Non-empty row count. dataRowCount: 0, // Non-empty column count. dataColumnCount: 0, // Cell with errors. errors: [] }; } function onOpenTagInSheetData(tagName, attributes, state) { if (tagName === 'row') { // Read an optional `r` attribute, which represents a 1-based row number. // If the `r` attribute is not present on a `<row>` then the row number is either defined by // `<c>` elements in it, or just assumed to be "previous row number + 1". if (attributes.r) { state.rowNumber = Number(attributes.r); } state.row = []; } else if (tagName === 'c') { state.c = createInitialStateInCell(); state.c.attributes = attributes; } else if (state.c) { onOpenTagInCell(tagName, attributes, state.c); } } function onCloseTagInSheetData(tagName, state) { if (tagName === 'row') { // If the row number is known, check for any inter-row gaps that it might introduce. if (state.rowNumber) { // Google AI says that `<row>` elements must be ordered by row number ascending, // so the code assumes that there can't be an out-of-order `<row>`. var previousRowNumber = state.rowIndexShift + state.rows.length; if (state.rowNumber <= previousRowNumber) { throw new InvalidSpreadsheetError("Out-of-place <row/> number ".concat(state.rowNumber, " follows <row/> number ").concat(previousRowNumber)); } // Insert empty rows where there're gaps between the rows. while (state.rowNumber > state.rowIndexShift + state.rows.length + 1) { state.rows.push([]); } } // Add the parsed row to the list of rows. state.rows.push(state.row); // Update the total non-empty row count. if (state.row.length > 0) { state.dataRowCount = state.rowNumber; } // Update the total row count. if (state.rowNumber > state.rowCount) { state.rowCount = state.rowNumber; } // Reset state. state.row = undefined; state.rowNumber = undefined; } else if (tagName === 'c') { var cell = parseCellFromXmlData(state.c); // Google AI says that the Open XML specification (ISO/IEC 29500) strictly dictates // that `<c>` elements within `sheet.xml` (or specifically `<sheetData>`) // must be written in ascending order of their `r` attributes (e.g., "A1", "B1", "C1"). if (cell.row < state.cursor[0] || cell.row === state.cursor[0] && cell.column <= state.cursor[1]) { throw new InvalidSpreadsheetError("Out-of-place <c/> at row ".concat(cell.row, " col ").concat(cell.column, " follows <c/> at row ").concat(state.cursor[0], " col ").concat(state.cursor[1])); } // Update the cursor. state.cursor[0] = cell.row; state.cursor[1] = cell.column; // If the `r` attribute is not present on a `<row>` then the row number is either defined by // `<c>` elements in it, or just assumed to be "previous row number + 1". if (!state.rowNumber) { state.rowNumber = cell.row; } // If the cell has an error. if (cell.error) { if (THROW_ON_FIRST_CELL_ERROR) { throwInvalidCellError(cell); } state.errors.push(cell); } // If the cell is not empty. else if (cell.value !== EMPTY_CELL_VALUE) { // Insert empty cells where there're gaps between the columns. while (cell.column > state.row.length + 1) { state.row.push(EMPTY_CELL_VALUE); } // Add the parsed cell value to the list of cell values in the current row. state.row.push(cell.value); // Update the total non-empty column count. if (cell.column > state.dataColumnCount) { state.dataColumnCount = cell.column; } } // Update the total column count. if (cell.column > state.columnCount) { state.columnCount = cell.column; } // Reset state. state.c = undefined; } else if (state.c) { onCloseTagInCell(tagName, state.c); } } function onTextInSheetData(text, state) { if (state.c) { onTextInCell(text, state.c); } } /** * Parses the XML values of a `<c/>` element into an object representing a cell value. * @param {object} — `{ attributes: Record<string,string>, inlineString?: string, vText?: string }`. If `<v/>` element is present but is empty, `vText` will be an empty string. If `<v/>` element is absent, `vText` will be `undefined`. * @returns {object} Either `{ row: number, column: number, error: string }` or `{ row: number, column: number, value: string|number|boolean|null }` */ function parseCellFromXmlData(_ref3) { var attributes = _ref3.attributes, inlineString = _ref3.inlineString, vText = _ref3.vText; var _parseCellAddress = parseCellAddress(attributes.r), _parseCellAddress2 = _slicedToArray(_parseCellAddress, 2), row = _parseCellAddress2[0], column = _parseCellAddress2[1]; var errorOrTypeAndValue = parseCellAndTrimValue(attributes.t, attributes.s, vText, inlineString, parseCellParameters, options.trim !== false); if (typeof errorOrTypeAndValue === 'string') { return { row: row, column: column, error: errorOrTypeAndValue // // Report the "raw" unparsed value of the cell for potential debugging. // // Also report the cell type and the format in case of a numeric value. // // // // For "inline string" cells, the value should actually be the `inlineString` argument // // rather than `vText` argument, but the only case when it could throw an error // // when parsing an "inline string" cell is `VALUE_MISSING` which means that // // `inlineString` argument is `undefined`, same as `vText` argument in this case, // // so the resulting `value` property is correct anyway. // // // value: vText, // type: attributes.t, // formatId: attributes.s }; } return { row: row, column: column, value: parseCellValue(errorOrTypeAndValue[1], errorOrTypeAndValue[0]) }; } // Here, it could also parse "merged cells" and then return them in some special way. // But then it's not clear what should be the way to return such merged cells. // I.e. should it just return it as a duplicate value in each one of the merged cells? // Or should it keep the current behavior of only returning the value of the top-most left-most cell // and then just return `null` for the rest of the cells in a "merged cells" group? // Perhaps the latter (current) approach is the most sensible one, so there's no need // to change anything. // // const mergedCells = getMergedCellCoordinates(sheetDocument) // for (const mergedCell of mergedCells) { // const [from, to] = mergedCell.split(':').map(parseCellAddress) // console.log('Merged Cell.', 'From:', from, 'To:', to) // } // Cell /** * Parses a cell from the info extracted from the cell XML. * If the cell is of type string, it trims the value (by default). * * Receives same arguments as `parseCell()` function, with an additional argument * `trimStrings: boolean` which tells if it should trim any string values. * * Produces same result as `parseCell()` function, except for cells of type "e". */ function parseCellAndTrimValue(t, s, v, inlineString, parameters, trimStrings) { // Parse cell value from cell XML. var errorOrTypeAndValue = parseCellWithRepairAbility(t, s, v, inlineString, parameters); // Trim any text cell values (by default) if (Array.isArray(errorOrTypeAndValue) && errorOrTypeAndValue[0] === 's') { // A developer could optionally disable the automatic trimming of all strings. // For example, leading spaces might express a tree-like hierarchy, in which case they should be preserved. // https://github.com/catamphetamine/read-excel-file/pull/106#issuecomment-1136062917 if (trimStrings) { errorOrTypeAndValue[1] = errorOrTypeAndValue[1].trim(); } // Convert empty strings to `null` if (errorOrTypeAndValue[1] === '') { return EMPTY_CELL; } } // Return the cell's type and value. return errorOrTypeAndValue; } /** * Parses cell value and optionally repairs any repairable errors. * Receives same arguments as `parseCell()` function. * Produces same result as `parseCell()` function, except for cells of type "e". */ function parseCellWithRepairAbility(t, s, v, inlineString, parameters) { // Parse cell value from cell XML. var errorOrTypeAndValue = parseCell(t, s, v, inlineString, parameters); // If the cell value was expected but is missing, // it is considered a repairable situation. if (errorOrTypeAndValue === 'VALUE_MISSING') { // The default cell type is "n" (numeric), according to XLSX specification. switch (t || 'n') { // * If the cell is defined by a formula. // * Or contains an inline string. // * Or contains a shared string. // * Or contains a boolean value. case 'str': case 'inlineStr': case 's': case 'b': // * If the formula result is not pre-computed (which is allowed by the spec) // then just ignore this cell. // * If the inline string value is not specified then just ignore this cell. // * If the shared string index is not specified then just ignore this cell. // * If the boolean value is not specified then just ignore this cell. return EMPTY_CELL; } } // If the cell contains a formula that couldn't be calculated due to an error, // simply ignore such cell and assume it to be empty. if (t === 'e') { return EMPTY_CELL; } // Return either the error code or the cell's type and value. return errorOrTypeAndValue; } /** * For certain types of cell, it transforms the value. * Specifically, for cells of type "n" or "d", it transforms the value to `Number` or `Date` respectively. * * The reason it is done separately is because before this function is called, * the cells are easily "serializable". And after this function is called, * some of the cells' `value` properties become instances of `Date` class or any other class, * such as `BigInt`, serializing which would require additional manual steps to be performed. * Serializing cells could be utilized in case of "transferring" data between workers * with a `transferList`, argument which could hypothetically result in better performance * and less time being blocked by the "synchronous" JSON serialization. * * @param {string|number|boolean|null} value * @param {string} type — One of: "s", "n", "d", "b", "-" * @return {string|ParsedNumber|Date|boolean|null} */ function parseCellValue(value, type) { if (type === 'n') { // Parse number from string. // Supports custom parsing function to work around the javascript number precision limitation. // https://gitlab.com/catamphetamine/read-excel-file/-/issues/85 if (options.parseNumber) { return options.parseNumber(value); } return value; } else if (type === 'd') { return new Date(value); } else { return value; } } function createInitialStateInCell() { return { v: false, is: false, t: false, r: false, rPh: false, vText: undefined, inlineString: undefined, attributes: undefined }; } function onOpenTagInCell(tagName, attributes, state) { if (tagName === 'v') { state.v = true; } else if (tagName === 'is') { // The possible children of <is> are: // * <t> (Text): The standard child to hold plain, simple text. // * <r> (Rich Text Run): Used for applying different formatting styles (like bold or italic) to specific segments of text within a single cell. // * <rPh> (Phonetic Run): Used primarily for East Asian languages to provide phonetic reading/pronunciation data (e.g., furigana in Japanese). It associates a phonetic pronunciation run right alongside the base string text tag <t>. // * <phoneticPr> (Phonetic Properties): Defines formatting and settings for the phonetic text. state.is = true; state.inlineString = ''; } else if (tagName === 't') { state.t = true; } else if (tagName === 'r') { // The possible children of <r/> are: // * <rPr> (Run Properties): The formatting properties for the text (font, size, color, bold, italic, etc.). // * <t> (Text): The actual text payload. // * <rPh> (Phonetic Run): Phonetic pronunciation guidance (used for East Asian languages like Japanese). It associates a phonetic pronunciation run right alongside the base string text tag <t>. state.r = true; } else if (tagName === 'rPh') { // The possible children of <rPh/> are: // * <t> (Text): Contains the actual phonetic text or reading (usually in Katakana for Japanese) that corresponds to the associated character string. state.rPh = true; } } function onCloseTagInCell(tagName, state) { if (tagName === 'v') { state.v = false; // If the `<v/>` element is present but is empty, // reassign `vText` property from `undefined` to an empty string `""`. // This is to differentiate between two separate cases: // * When `<f/>` formula is present but its result is not pre-computed (`<v/>` is absent) // * When `<f/>` formula is present and its result is an empty string (`<v/>` is present but empty) state.vText || (state.vText = ''); } else if (tagName === 'is') { state.is = false; } else if (tagName === 't') { state.t = false; } else if (tagName === 'r') { state.r = false; } else if (tagName === 'rPh') { state.rPh = false; } } function onTextInCell(text, state) { if (state.v) { state.vText = text; } else if (state.is) { if (state.rPh) { // Ignore anything inside `<rPh/>` tags } else if (state.t) { if (state.r) { // An `<r/>` element could contain multiple `<t/>` elements, // the text content from all of which should be concatenated. state.inlineString += text; } else { state.inlineString = text; } } } } // Dimensions function getSheetDimensions(cells) { // The left-top boundary is ignored and is always assumed to be `0, 0`. // It used to be not ignored in the past but that produced confusing behavior // when empty rows or columns at the start of a sheet were discarded, // and that was not something that users of this package expected. // https://github.com/catamphetamine/read-excel-file/issues/102#issuecomment-973238655 var minRow = cells.length === 0 ? 0 : 1; var minCol = cells.length === 0 ? 0 : 1; var maxRow = 0; var maxCol = 0; for (var _iterator2 = _createForOfIteratorHelperLoose(cells), _step2; !(_step2 = _iterator2()).done;) { var cell = _step2.value; if (maxRow < cell.row) { maxRow = cell.row; } if (maxCol < cell.column) { maxCol = cell.column; } } return [[minRow, minCol], [maxRow, maxCol]]; } } //# sourceMappingURL=parseSheet.js.map