ConsultChimps

Compose with libraries

Use the focused ConsultChimps TypeScript packages without the CLI.

The CLI is intentionally thin. File discovery, table operations, workbook adapters, PowerPoint operations, and PDF operations live in focused packages that can be reused in a server, desktop app, workflow runner, or another CLI.

Package map

PackageResponsibility
@consultchimps/coreShared result contracts and structured errors
@consultchimps/filesFile discovery and safe output handling
@consultchimps/tabularRuntime-neutral table union, grouping, and column mapping
@consultchimps/themeRuntime-neutral palette model and colour validation
@consultchimps/dbPersistent SQLite/DuckDB databases, imports, and batch history
@consultchimps/xlsxExcel reading, writing, merging, consolidation, and splitting
@consultchimps/pptxPowerPoint template inspection and population
@consultchimps/pdfPDF splitting and merging
@consultchimps/messagesPlain-language rendering of results and errors

Discover inputs

import { discoverFiles } from "@consultchimps/files";

const workbooks = await discoverFiles(
  ["January.xlsx", "regional/", "late/*.xlsx"],
  { extensions: [".xlsx"] },
);

The result contains unique, absolute, sorted paths.

Union in-memory tables

import { unionTables, type Table } from "@consultchimps/tabular";

const tables: Table[] = [
  {
    columns: ["Client", "Amount"],
    rows: [{ Client: "Acme", Amount: 1200 }],
    source: { file: "north.xlsx", sheet: "Revenue", firstDataRow: 2 },
  },
  {
    columns: ["client", "Status"],
    rows: [{ client: "Globex", Status: "Open" }],
    source: { file: "south.xlsx", sheet: "Revenue", firstDataRow: 2 },
  },
];

const combined = unionTables(tables);

The output columns are Client, Amount, Status, and the three provenance columns. Case-insensitive headers are aligned without changing the original spelling of the first header encountered.

Map columns onto one schema

When the same field arrives under different headers (Case ID from one system, Reference from another), a column mapping folds them into one canonical column before the tables are unioned. A mapping is a versioned JSON document: canonical columns with their aliases, optional coercions, and constant columns. Reading the file is your job; the library takes the parsed object, so it stays free of any filesystem dependency.

import {
  applyColumnMappingToTables,
  unionTables,
  validateColumnMapping,
  type Table,
} from "@consultchimps/tabular";

const mapping = validateColumnMapping({
  version: 1,
  columns: [
    { name: "Case_ID", aliases: ["Reference", "Case Number"] },
    {
      name: "Opened_On",
      aliases: ["Run Time"],
      coercion: { type: "date", format: "DD/MM/YYYY" },
    },
    {
      name: "Amount",
      aliases: ["Total"],
      coercion: {
        type: "number",
        decimalSeparator: ",",
        thousandsSeparator: ".",
      },
    },
  ],
  constants: { Dataset: "quarterly" },
});

const tables: Table[] = [
  {
    columns: ["Case ID", "Run Time", "Total", "Region"],
    rows: [
      {
        "Case ID": 1,
        "Run Time": "09/03/2024",
        Total: "1.234,50",
        Region: "north",
      },
    ],
    source: { file: "north.xlsx", sheet: "Cases", firstDataRow: 2 },
  },
  {
    columns: ["reference", "Region"],
    rows: [{ reference: 2, Region: "south" }],
    source: { file: "south.xlsx", sheet: "Cases", firstDataRow: 2 },
  },
];

const mapped = applyColumnMappingToTables(tables, mapping);
const combined = unionTables(mapped.tables);

if (mapped.unmappedColumns.length > 0) {
  console.warn(`Columns kept as-is: ${mapped.unmappedColumns.join(", ")}`);
}

The first row becomes Case_ID: 1, Opened_On: "2024-03-09", Amount: 1234.5, Region: "north", Dataset: "quarterly", plus the provenance columns. Region is an unmapped column: it passes through under its own name and is reported in unmappedColumns so you can warn about it: loud, but lossless.

Points worth knowing before you write a mapping:

  • Aliases match by normalized column key. One alias entry catches every case, spacing, and punctuation variant of that spelling, so Reference, reference, and Reference: all match the same entry, and a canonical column always matches its own name. Canonical names are written to the output verbatim.
  • Coercions are strict. A date is parsed from the format you declare (YYYY, MM or M, DD or D; every other character is a literal separator) into an ISO 8601 date string. A number is parsed with the separators you declare, defaulting to , for thousands and . for decimals. Blank cells stay blank, but a value that is not what its coercion declares stops the run with TABLE_MAPPING_COERCION_FAILED naming the row and column, rather than being written through unconverted.
  • Ambiguity is refused, never guessed. Two columns of one table folding into a single canonical column raise TABLE_MAPPING_COLUMN_COLLISION naming the file, sheet, both columns, and the canonical column, because merging them would drop values. validateColumnMapping refuses a document that is ambiguous on its own terms (an unknown version, two canonical columns that normalize alike, an alias claimed twice) with TABLE_MAPPING_INVALID and a machine-readable problem in the error details.
  • Constant columns come last, after the mapped and unmapped columns.

Apply a mapping per source table, before unionTables, as above: that is what lets a collision name the sheet it came from. applyColumnMapping does the same for a single table.

Draft a mapping from the headers you have

suggestColumnMapping reads header lists and groups the columns whose normalized keys already match (the spelling variants of one field), proposing the first spelling seen as the canonical name:

import { suggestColumnMapping } from "@consultchimps/tabular";

const suggestion = suggestColumnMapping([
  { columns: ["Case ID", "Failed Checks"], file: "north.xlsx", sheet: "Cases" },
  { columns: ["Case_ID", "failed_checks"], file: "south.xlsx", sheet: "Cases" },
]);

// suggestion.mapping is a draft version 1 document to review and edit.
// suggestion.groups carries the evidence: the spellings each entry folds and
// where every one of them was seen.

The suggestion uses no similarity scoring and no sample values: it groups only deterministic normalization equivalence, and it never guesses. That is not the same as never being wrong. Headers that differ only in punctuation, such as A+B and A-B, normalize to the same key and will group even when they mean different things, which is why every draft is reviewed before use. Synonyms that share no spelling, such as Timestamp and Run Time, stay for you to add by hand. A drafted entry carries no aliases on purpose: every spelling in a group shares one normalized key, which the canonical name already matches. Review the draft and apply it yourself; nothing is applied for you.

Resolve and validate a palette

@consultchimps/theme holds a runtime-neutral palette model: categorical, sequential, and semantic colours, each with a light and a dark value, plus a resolve API and a validation pass. It has no dependencies. A client's real colours are supplied at runtime; the package ships neutral placeholder palettes only.

import {
  NEUTRAL_PALETTE,
  resolveCategorical,
  resolveSemantic,
  validatePalette,
  contrastRatio,
} from "@consultchimps/theme";

// Resolve a colour role to a value for a mode.
const seriesOne = resolveCategorical(NEUTRAL_PALETTE, 0, "light"); // "#2a78d6"
const critical = resolveSemantic(NEUTRAL_PALETTE, "critical", "dark");

// Validate contrast and categorical distinctness. The pass returns a report
// rather than throwing, so a caller can surface each issue.
const report = validatePalette(NEUTRAL_PALETTE, "light");
if (!report.valid) {
  for (const issue of report.issues) {
    console.warn(`${issue.check}: ${issue.message}`);
  }
}

// The WCAG contrast ratio between two colours, for a one-off check.
console.log(contrastRatio("#0b0b0b", "#fcfcfb"));

A report is { valid, issues[] }. valid is true when no issue has error severity; a contrast shortfall against the surface is reported as a warning, so a palette that leans on visible labels still validates. resolveCategorical throws when a slot is past the palette's range, since categorical slots are assigned in order rather than cycled.

Read workbooks without writing anything

The xlsx package also exposes the readers the operations are built on, for callers that want the data rather than an output file:

import {
  readWorkbookTables,
  readWorksheetRecords,
  readWorkbookExcelTables,
  readWorkbookNamedRanges,
} from "@consultchimps/xlsx";

// Every visible, non-empty worksheet as a Table (columns, rows, source).
const tables = await readWorkbookTables("clients.xlsx");

// One worksheet as records keyed by header, using Excel's stored display text.
const records = await readWorksheetRecords("clients.xlsx", {
  worksheet: "Clients",
});

// Defined Excel Tables and named ranges, with their data.
const excelTables = await readWorkbookExcelTables("clients.xlsx");
const namedRanges = await readWorkbookNamedRanges("clients.xlsx");

readWorkbookTables accepts the consolidator's selection options: sheets, a one-based headerRow, and includeHiddenSheets. Without a headerRow the header is detected the way the consolidator detects it: the first row holding more than half as many values as the fullest of the rows below it, or at most one value fewer, with the rows above it skipped when a blank row sets them apart from the header or they are banners merged across columns (see Choose a header row). Columns that hold nothing in the header row or under it are left out as spacers, and readWorkbookWorksheets reports both counts per worksheet as skippedTitleRows and skippedSpacerColumns. readWorkbookTablesBytes is its byte-level twin, for a browser that holds the workbook's bytes rather than a path. readWorksheetRecords selects one worksheet and a headerRow, and returns uncachedFormulas, the Sheet!B4 cells it read as empty because Excel never calculated them; the Excel Table and named-range readers filter by sheets plus tables or names. The writeTable counterpart writes one in-memory Table to a new formatted workbook.

Inspect a workbook before operating on it

describeWorkbook reports what is in a file without creating anything: worksheets with their visibility and dimensions, the header row the region resolver picks (the one a consolidation reads from and a split keys on), Excel Tables, named ranges, and a bounded sample of every column's values. The region resolver and the table readers apply one header rule, so the header row and columns the description reports are the ones readWorkbookTables returns. Uncalculated formulas are where the two still differ, because a formula with no cached result occupies its row for the description and holds nothing for a table reader. A worksheet where no row holds a readable value at all is described from its first used row, since the sheet is not empty, while the table readers yield no table for it; and a worksheet whose header is readable but whose rows under it hold only such formulas is described with its header, its columns, and those rows counted as data rows, while the table readers see no values under the header and yield no table. Either way readWorkbookWorksheetsBytes reports the uncalculated cells beside the read. Use the description to confirm a workbook's shape, or to build a picker for the sheets and columns another operation will act on.

import { describeWorkbook } from "@consultchimps/xlsx";

const { description, result } = await describeWorkbook("review-log.xlsx", {
  includeHiddenSheets: true,
});

for (const sheet of description.sheets) {
  console.log(`${sheet.name} (${sheet.visibility})`);
  console.log(`  header row: ${sheet.headerRow ?? "none found"}`);
  for (const column of sheet.columns) {
    console.log(`  ${column.header}: ${column.sampleValues.join(", ")}`);
  }
}

for (const table of description.excelTables) {
  console.log(`Excel Table ${table.name} on ${table.sheet} (${table.range})`);
}

console.log(result.metrics.worksheets, result.warnings);

The inspection creates nothing, so the outcome pairs the description with an OperationResult that has no artifacts: its metrics are the counts, and its warnings name what a later operation would stumble on: hidden worksheets left out of the description, or a worksheet with no header row to match columns by.

Sample values are deliberately bounded: at most MAX_COLUMN_SAMPLE_VALUES (5) distinct non-empty stored values per column, in the order the rows carry them. Pass sampleValues to ask for fewer. Nothing is type-inferred: the values are reported exactly as the workbook stores them, so 1 and "1" stay distinct. headerRow, includeHiddenSheets, and sheets mean what they mean to every other reader here; naming a worksheet the workbook does not have is refused with XLSX_WORKSHEET_NOT_FOUND rather than answered with an empty description.

describeWorkbookBytes is the byte-level twin and produces a structurally identical description for the same workbook.

Notice cells a table cannot represent

Two kinds of cell reach a Table as something they are not.

Excel writes a formula and its last calculated result side by side, but a file written by a generator, or saved with calculation switched off, carries the formula alone. Every reader then sees those cells as empty, because empty is all the file says, and a table read from that worksheet has holes where the numbers belong.

An error cell reaches it as a number. #REF!, #DIV/0! and their kind are stored as t="e", and the spreadsheet engine reports the internal code Excel numbers each error by, so a #REF! arrives as 23 and a #DIV/0! as 7. That is worse than a hole: a column of amounts still infers a numeric type, and nothing downstream can tell those codes from data.

readWorkbookWorksheetsBytes reports each selected worksheet, whether or not it yielded a table, with the rectangle the read covered and the count of each kind inside it:

import { readWorkbookWorksheetsBytes } from "@consultchimps/xlsx/bytes";

for (const worksheet of await readWorkbookWorksheetsBytes({
  name: file.name,
  bytes,
})) {
  if (worksheet.uncachedFormulaCells > 0) {
    // Calculating them belongs to Excel: open the workbook, let it calculate,
    // and save it again.
    console.log(`${worksheet.sheet} has values the file does not carry`);
  }
  if (worksheet.errorCells > 0) {
    // What a broken formula was meant to say is not in the file at all: fix or
    // clear the errors in Excel and save it again.
    console.log(`${worksheet.sheet} has values that are errors`);
  }
}

readWorkbookWorksheets is the file twin: both surfaces hand the same operation the same bytes, so neither can answer differently about the same workbook.

A cell a worksheet stores as a date reads as ISO 8601 text, and that text is a function of the workbook alone. A worksheet says a cell is a date in two ways, and both are read: a count of days from the workbook's epoch wearing a date number format, which is what Excel writes, and a cell that declares t="d" and writes ISO 8601 text, which the format allows and other generators use. The declared type is enough on its own; a cell that declares itself a date and wears no format is still a date, and reads as one.

Neither carries a time zone, so the reader decodes what the workbook holds into calendar components and writes them out. No Date takes part: a Date has a local face as well as a UTC one, and text built from the local face makes the same workbook read as a different calendar day in every time zone. The same workbook therefore reads identically on a machine set to UTC, to UTC+4, or to UTC-7, which is what a group key, an output filename, and a stored date all need. A declared date whose text names no moment is carried as that text rather than converted.

The text is always the full timestamp, 2024-01-01T00:00:00.000Z, whether or not the cell carries a time, so a date column reads the same way down its whole length. @consultchimps/db accepts that spelling and the bare 2024-01-01 in a date column, so either travels.

Consolidate, a compact split and writeTable write a value in that spelling back as an Excel date: a serial in the 1900 date system, formatted yyyy-mm-dd, or yyyy-mm-dd hh:mm:ss when it carries a time. A date before 1900 has no serial in that system and stays text.

A moment carries a millisecond, and a declared date written finer than that is carried as its text rather than shortened to fit: 18:00:00.1234 and 18:00:00.1239 are not the same instant, and reading both as 18:00:00.123 would change what the cell says and let a split gather rows that are not together. Digits past the third that are zeros lose nothing and are accepted. The same rule covers every other shape the text can take, and the whole list is enumerated in the reader itself, beside the grammar.

Both counts are read from the package's own document model, because the spreadsheet engine behind the table readers drops a numeric formula cell with no cached value while parsing and flattens an error into that code, and they are scoped in one walk to the rectangle the table reader reported, so they describe the read you got rather than a region resolved a second time. A cached value counts as present when the element is there, whatever it holds, so a formula that evaluated to an empty string or to zero has been calculated; an error counts whether somebody typed it in or a formula left it there. readWorkbookTablesBytes is this list with the tables taken out of it, and the consolidation, merge, and split operations are unchanged: they go on treating an uncalculated formula as an empty cell and an error as the code it is stored as.

A worksheet part is parsed the first time something asks for it, so a malformed row or cell reference surfaces long after the workbook opened. Whichever step it comes from, it is reported as XLSX_READ_FAILED, naming the workbook and, when the failure belongs to a worksheet, that worksheet; the parser's own complaint is the error's cause.

Remove worksheet and workbook protection

unprotectWorkbook removes ordinary worksheet (sheetProtection) and workbook-structure (workbookProtection) protection from an .xlsx or .xlsm workbook without needing the protection password, writing a new file and leaving the source untouched. It edits a copy of the package, so formulas, formatting, hidden worksheets, tables, and any macro project travel across unchanged. It is not password cracking: a workbook encrypted to require a password to open is not supported and is reported with XLSX_UNPROTECT_UNSUPPORTED_FILE.

import { unprotectWorkbook } from "@consultchimps/xlsx";

const result = await unprotectWorkbook({
  input: "protected.xlsx",
  output: "unprotected.xlsx",
});

console.log(result.metrics.sheetProtectionsRemoved); // e.g. 3
console.log(result.metrics.workbookProtectionsRemoved); // e.g. 1

An output named .xlsx or .xlsm has to match the workbook's declared type: an ordinary workbook stays .xlsx and a macro-enabled workbook stays .xlsm. A name that claims the wrong one of the two is refused with XLSX_UNPROTECT_PACKAGE_TYPE_MISMATCH before anything is written, because a package whose contents and name disagree is one Excel opens with a corruption warning. unprotectWorkbookBytes is the byte-level twin, taking and returning in-memory bytes for callers with no filesystem.

Read what an Excel operation promises

@consultchimps/xlsx exports its conformance contract: for every workbook structure it tracks and every operation it offers, what that operation does. The value is preserve, fix (rewritten so it stays valid), strip-warn (removed, with a warning in the result), or refuse. The package's corpus tests exercise the table cell by cell, so it states behavior rather than intent.

import { CONTRACT, TRACKED_STRUCTURES } from "@consultchimps/xlsx";

const removedByADefaultSplit = TRACKED_STRUCTURES.filter(
  (structure) => CONTRACT.split[structure] === "strip-warn",
);
// ["pivot-tables"]

CONTRACT.split describes the default whole-workbook split, the one that edits a copy of your workbook. The other selectors do not follow it, so do not quote these cells at a caller who chose one:

  • preserveWorkbook: false, range, and sheet rebuild a single worksheet from the grouped values and carry none of these structures: no other worksheets, no charts, no macros.
  • table runs the preserved-table rewrite, which compacts the table's rows. Because compaction moves rows, an ordinary A1 formula inside that table stops the operation with XLSX_SPLIT_PRESERVE_FORMULA instead of being repaired the way this table's fix cell describes.

A structure with no entry for an operation is one the package has not decided yet; UNDECIDED_SPLIT_STRUCTURES, UNDECIDED_MERGE_STRUCTURES, UNDECIDED_DESCRIBE_STRUCTURES, and UNDECIDED_UNPROTECT_STRUCTURES record why each one is still open. CONTRACT.unprotect, for example, declares every tracked structure preserve except external-links, which stays open because no corpus fixture can exercise it yet.

A cell states what an operation ordinarily does, not what a particular run will do: it carries no options, no input ordering, and no mode, so a behavior that depends on one of those cannot be read off it. merge["vba-project"] is strip-warn, for instance, even though a macro project survives when the first input is the only one carrying it and the output is named .xlsm. Use a cell to tell people what usually happens and read the conditions beside it in what the Excel operations preserve, which is generated from this table and carries them; treating a cell as a verdict on one run will sometimes be wrong.

Write values without losing Excel formatting

The Excel consolidation, merge, and split operations accept values: true. For operations that preserve a source workbook, formula elements are removed directly from the XLSX package while cached results and formatting remain in place:

import { mergeWorkbooks } from "@consultchimps/xlsx";

await mergeWorkbooks(["north.xlsx", "south.xlsx"], "outputs/all-sheets.xlsx", {
  values: true,
});

Consolidation and compact data-only splits already create value cells, so the same option is accepted there to make the caller's intent consistent across all Excel operations.

Handle structured failures

import {
  isConsultChimpsError,
  type OperationResult,
} from "@consultchimps/core";
import { splitPdf } from "@consultchimps/pdf";

async function run(): Promise<OperationResult | undefined> {
  try {
    return await splitPdf({
      input: "report.pdf",
      outputDirectory: "outputs/pages",
    });
  } catch (error) {
    if (isConsultChimpsError(error)) {
      console.error(error.code, error.details);
      return;
    }

    throw error;
  }
}

Known errors carry a stable code, a human-readable message, optional structured details, and the original cause when available.

Preview an operation before executing it

Every file-producing operation has a plan variant that validates the inputs and computes every intended output without writing anything. Use it to show a confirmation screen before running the real operation.

import { planSplitPdf, splitPdf } from "@consultchimps/pdf";

const plan = await planSplitPdf({
  input: "report.pdf",
  outputDirectory: "outputs/pages",
});

for (const output of plan.outputs) {
  console.log(output.path, output.exists ? "(would need overwrite)" : "");
}

const result = await splitPdf({
  input: "report.pdf",
  outputDirectory: "outputs/pages",
});

The plan lists absolute input paths, every planned output with an exists flag for collisions, deterministic metrics, and warnings such as skipped rows. planConsolidateWorkbooks, planSplitWorkbookByColumn, planMergePdfs, and planPopulatePowerPointTemplate follow the same shape.

Report progress and support cancellation

Operations accept an optional onProgress reporter and an AbortSignal. Progress events are deterministic for identical inputs; aborting stops the operation before its next unit of work with a stable OPERATION_ABORTED error. Source files are never modified by a cancelled operation, although output files completed before the cancellation may remain.

import { mergePdfs } from "@consultchimps/pdf";

const controller = new AbortController();

const result = await mergePdfs({
  inputs: ["first.pdf", "second.pdf"],
  output: "outputs/combined.pdf",
  signal: controller.signal,
  onProgress: ({ stage, completed, total, detail }) => {
    console.log(`${stage}: ${completed}/${total} ${detail ?? ""}`);
  },
});

Run operations without a filesystem

Browsers and other filesystem-free environments use the byte-level entry points @consultchimps/pdf/bytes, @consultchimps/xlsx/bytes, and @consultchimps/pptx/bytes. Named in-memory bytes go in; the produced bytes come out alongside the same structured result the path-based operations report, and the import graph contains no Node built-ins:

import {
  mergePdfsBytes,
  planSplitPdfBytes,
  splitPdfBytes,
} from "@consultchimps/pdf/bytes";

const plan = await planSplitPdfBytes({
  input: { name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) },
});
console.log(plan.outputs.map((output) => output.path));

const { result, outputs } = await splitPdfBytes({
  input: { name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) },
  signal: controller.signal,
  onProgress: ({ completed, total }) => updateProgress(completed / total),
});

for (const output of outputs) {
  download(output.name, output.bytes, output.mediaType);
}

The workbook and presentation operations follow the same shape. splitWorkbookBytes mirrors the path-based split, defaults included: with no worksheet, Excel Table, or named range named, it filters every worksheet that carries the column and each output keeps the rest of the workbook intact. Naming one of those sources, or passing preserveWorkbook: false, selects the single-source modes, which are also the only modes where includeBlank and includeHiddenSheets apply. strict compares values without trimming, case folding, or numeric coercion. mergeWorkbooksBytes combines every worksheet of every input, keeping tabs and formatting and adding the same Sheet Index; consolidateWorkbooksBytes stacks the rows of every visible worksheet that holds data into one table (hidden and very hidden worksheets are skipped unless includeHiddenSheets is set) with the same normalizeHeaders, addSourceColumns, worksheet-selection, and header-row options as the path-based consolidation; and populatePresentationBytes fills a template presentation from either records you already hold or the bytes of a workbook.

import {
  consolidateWorkbooksBytes,
  mergeWorkbooksBytes,
  planSplitWorkbookBytes,
  splitWorkbookBytes,
} from "@consultchimps/xlsx/bytes";
import { populatePresentationBytes } from "@consultchimps/pptx/bytes";

const { outputs } = await splitWorkbookBytes({
  input: { name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) },
  column: "Region",
});

// One combined table from several exports of the same schema, even when the
// headers drift between them.
const consolidated = await consolidateWorkbooksBytes({
  inputs: [
    { name: "north.xlsx", bytes: northBytes },
    { name: "south.xlsx", bytes: southBytes },
  ],
  normalizeHeaders: true,
  outputName: "review-log",
  signal: controller.signal,
  onProgress: ({ stage, completed, total }) => {
    console.log(`${stage}: ${completed}/${total}`);
  },
});
console.log(consolidated.result.metrics.outputRows);
download(
  consolidated.outputs[0].name,
  consolidated.outputs[0].bytes,
  consolidated.outputs[0].mediaType,
);

const populated = await populatePresentationBytes({
  template: { name: "template.pptx", bytes: templateBytes },
  records: [{ client: "North", amount: "10" }],
});

consolidateWorkbookSources takes the same options but holds neither the inputs nor the output whole. Each input is a random-access source, such as blobSource over a browser File, read in pieces rather than whole, and the workbook goes to your output sink chunk by chunk. The sink receives the bytes consolidateWorkbooksBytes would return, and its abort runs when the consolidation fails or is cancelled, to discard what was written. In a Web Worker, an Origin Private File System sync access handle makes a sink that keeps the output on disk.

import {
  blobSource,
  consolidateWorkbookSources,
} from "@consultchimps/xlsx/bytes";

// Any writable destination works; this one collects Blob parts.
let parts: Uint8Array[] = [];
const { outputName } = await consolidateWorkbookSources({
  inputs: files.map((file) => blobSource(file.name, file)),
  output: {
    write: (chunk) => {
      parts.push(chunk);
    },
    flush: () => Promise.resolve(),
    // Runs when the consolidation fails or is cancelled.
    abort: async () => {
      parts = [];
    },
  },
});
const workbook = new File(parts, outputName);

A template inspection reads a slide and writes nothing, so inspectPresentationOutcomeBytes returns the structured result beside the placeholder report rather than beside output bytes. Its metrics are the slide's counts, and its warnings name every condition (malformed braces, unsupported placements, no placeholders at all) that would make a population refuse the template:

import { inspectPresentationOutcomeBytes } from "@consultchimps/pptx/bytes";

const { inspection, result } = await inspectPresentationOutcomeBytes(
  { name: "template.pptx", bytes: templateBytes },
  { templateSlide: 2 },
);

console.log(result.metrics.placeholderOccurrences);
for (const placeholder of inspection.placeholders) {
  console.log(placeholder.name, placeholder.occurrences);
}
for (const warning of result.warnings) {
  console.warn(warning);
}

inspectPresentationBytes remains available and returns the placeholder report on its own, for callers that want the reading without the operation result.

A workbook inspection works the same way. describeWorkbookBytes returns the description beside the structured result, and the bytes surface also carries readWorkbookExcelTablesBytes and readWorkbookNamedRangesBytes, the byte twins of the path-based Excel Table and named-range readers:

import {
  describeWorkbookBytes,
  readWorkbookExcelTablesBytes,
} from "@consultchimps/xlsx/bytes";

const input = {
  name: file.name,
  bytes: new Uint8Array(await file.arrayBuffer()),
};

const { description } = await describeWorkbookBytes(input, {
  sampleValues: 3,
});
renderSheetPicker(description.sheets);

const excelTables = await readWorkbookExcelTablesBytes(input);

Output names default to the input name (report.pdf becomes report-page-001.pdf, clients.xlsx becomes clients-North.xlsx, and template.pptx becomes template-populated.pptx; workbook merges default to merged.xlsx, workbook consolidations to consolidated.xlsx, and PDF merges to combined.pdf) and are sanitized to stay portable, including on Windows. Outputs are byte-deterministic, and a cancelled byte operation returns nothing and leaves nothing behind. Artifact paths in the structured result carry these output names rather than filesystem paths.

Explain results to people

The @consultchimps/messages package renders any OperationResult or structured error as detailed, plain-language text, so a desktop or web interface can reuse one voice.

import { formatHumanError, formatHumanResult } from "@consultchimps/messages";

console.log(formatHumanResult(result));

Recovery guidance is worded for the interface that shows it. By default the explanations use interface-neutral wording that never names a flag or an executable. Pass a MessageVocabulary to match your own interface, or pass the exported CLI_VOCABULARY to reproduce the wording the consultchimps command prints.

import {
  CLI_VOCABULARY,
  formatHumanError,
  GENERIC_VOCABULARY,
  type MessageVocabulary,
} from "@consultchimps/messages";

console.error(
  formatHumanError("The output file already exists.", "FILES_OUTPUT_EXISTS", {
    vocabulary: CLI_VOCABULARY,
  }),
);

// Start a custom interface's vocabulary from the neutral defaults so no
// command-line phrasing leaks through, then override what your interface
// presents differently.
const desktopVocabulary: MessageVocabulary = {
  ...GENERIC_VOCABULARY,
  artifactListReference: "in the results panel",
  retryWithOverwrite:
    "If you intentionally want to replace the existing output, switch on Replace existing files and try again.",
};

Populate a PowerPoint template

import { populatePowerPointTemplate } from "@consultchimps/pptx";

const result = await populatePowerPointTemplate({
  templatePath: "profile-template.pptx",
  workbookPath: "companies.xlsx",
  headerRow: 1,
  outputPath: "company-profiles.pptx",
});

Set overwrite: true only when an existing output presentation should be replaced. The first worksheet and first template slide are used by default; set worksheet or templateSlide to select another one. The source template and workbook remain unchanged.

On this page