Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 34 additions & 28 deletions README.md

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion src/codecs/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { DOCUMENT_FORMAT_CODECS } from './registry';

// Proves each DOCUMENT_FORMAT_CODECS entry's own read/write pair is wired correctly on its own terms -- not merely that readDocumentMetadata/setDocumentMetadata/buildDocumentBytes happen to still work after being refactored onto this registry (their own test files cover that). Every format with both a content.read and a content.write is exercised as a genuine read -> write -> read round trip: the content a fresh read produces after writing back out must equal the content that went in.

function requireContentCodec(format: 'docx' | 'pptx' | 'odt' | 'odp' | 'ods' | 'odg' | 'markdown' | 'xlsx') {
function requireContentCodec(format: 'docx' | 'pptx' | 'odt' | 'odp' | 'ods' | 'odg' | 'markdown' | 'xlsx' | 'csv') {
const content = DOCUMENT_FORMAT_CODECS[format].content;
if (!content?.write) {
throw new Error(`expected DOCUMENT_FORMAT_CODECS.${format}.content.write to be defined`);
Expand Down Expand Up @@ -101,6 +101,15 @@ describe('DOCUMENT_FORMAT_CODECS: content read/write round trips', () => {
expect(codec.read(rebuiltBytes)).toEqual(content);
});

// csv's round trip is exact-equality like markdown's, for the same stability reason: write emits each cell's displayText, and read re-types that text heuristically -- but re-typing a cell that already went through inferCellValue once lands on the identical value again (a re-typed number prints back as the same digits, a declined string stays a string), so a second read cannot drift from the first.
it('csv: read -> write -> read round-trips the ContentDocument', () => {
const codec = requireContentCodec('csv');
const csvBytes = new TextEncoder().encode('Name,Amount,Active\nWidget,42.5,TRUE\nGadget,7,\n');
const content = codec.read(csvBytes);
const rebuiltBytes = codec.write!(content);
expect(codec.read(rebuiltBytes)).toEqual(content);
});

// xlsx's own column-width unit conversion (ooxml.js's ptToColumnWidthChars/columnWidthCharsToPt, src/typed/xlsx/units.ts) is a best-effort algebraic inverse, not an exact one -- src/convert/bridges.test.ts's own COLUMN_WIDTH_TOLERANCE_PT documents up to ~1pt of drift per pt<->character-width hop. This registry round trip is a second such hop on top of whatever odsToXlsx's own bridge already introduced building the fixture, so widths are checked within tolerance rather than exact equality; every other field (sheet name, cell values/kinds/formula/merges) is checked exactly, since none of those go through a lossy unit conversion.
it('xlsx: read -> write -> read carries sheet cell values, kinds, formulas, and merges through exactly, and column widths within tolerance', () => {
const codec = requireContentCodec('xlsx');
Expand Down Expand Up @@ -161,3 +170,10 @@ describe('DOCUMENT_FORMAT_CODECS: xlsx has a content codec, no layout codec', ()
expect(DOCUMENT_FORMAT_CODECS.xlsx.layout).toBeUndefined();
});
});

describe('DOCUMENT_FORMAT_CODECS: csv has a content codec, no layout codec', () => {
it('csv has a content entry and no layout entry', () => {
expect(DOCUMENT_FORMAT_CODECS.csv.content).toBeDefined();
expect(DOCUMENT_FORMAT_CODECS.csv.layout).toBeUndefined();
});
});
10 changes: 10 additions & 0 deletions src/codecs/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { decodeMarkdownText, encodeMarkdownText } from '../markdown/text';
import type { MarkdownImageResolver } from 'markdown-codec';
import { readMarkdownContent } from '../markdown/read';
import { buildMarkdownText } from '../markdown/write';
import { decodeCsvText, encodeCsvText } from '../csv/text';
import { readCsvContent } from '../csv/read';
import { buildCsvText } from '../csv/write';
import { readOdfFormulaContent } from '../odf/formula/read';
import { readOdgContent } from '../odf/odg/read';
import { readOdpContent } from '../odf/odp/read';
Expand Down Expand Up @@ -116,6 +119,13 @@ export const DOCUMENT_FORMAT_CODECS: Readonly<Record<DocumentFormat, DocumentFor
write: (content) => encodeMarkdownText(buildMarkdownText(content)),
},
},
// The csv entry is the markdown entry's structural twin: decode straight from bytes to text (no package), read into a ContentDocument, write the reverse. DocumentCodecOptions carries no delimiter/sheet, so this codec reads and writes the default comma dialect over a lone sheet -- a caller wanting TSV output or a named sheet of a multi-sheet document uses the named conversions (convert.ts's xlsxToCsv/odsToCsv/pdfToCsv), which thread { delimiter, sheet } through UnifiedConversionOptions; a multi-sheet write through THIS codec throws buildCsvText's own CsvSheetNotSpecifiedError rather than silently truncating. decodeCsvText is a fatal decoder with no loop of its own, so no separate signal check is needed -- the same reasoning as the markdown entry beside it.
csv: {
content: {
read: (bytes) => readCsvContent(decodeCsvText(bytes)),
write: (content) => encodeCsvText(buildCsvText(content)),
},
},
pdf: {
layout: {
read: (bytes, options) => readPdf(requireArrayBufferBytes(bytes), { signal: options?.signal }),
Expand Down
28 changes: 26 additions & 2 deletions src/convert/capability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ describe('FORMAT_CAPABILITIES', () => {

expect(new Set(byVariant.get('wordprocessing'))).toEqual(new Set(['docx', 'odt', 'markdown']));
expect(new Set(byVariant.get('presentation'))).toEqual(new Set(['pptx', 'odp']));
expect(new Set(byVariant.get('spreadsheet'))).toEqual(new Set(['xlsx', 'ods']));
expect(new Set(byVariant.get('spreadsheet'))).toEqual(new Set(['xlsx', 'ods', 'csv']));
expect(new Set(byVariant.get('drawing'))).toEqual(new Set(['odg']));
});

it('is the one node sharing a variant with a layout-path sibling but having no layout path of its own', () => {
it('marks xlsx and csv as the spreadsheet members with no layout path of their own (ods carries the layout edge)', () => {
expect(FORMAT_CAPABILITIES.xlsx.variant).toBe('spreadsheet');
expect(FORMAT_CAPABILITIES.xlsx.hasLayoutPath).toBe(false);
expect(FORMAT_CAPABILITIES.csv.variant).toBe('spreadsheet');
expect(FORMAT_CAPABILITIES.csv.hasLayoutPath).toBe(false);
expect(FORMAT_CAPABILITIES.ods.variant).toBe('spreadsheet');
expect(FORMAT_CAPABILITIES.ods.hasLayoutPath).toBe(true);
});
Expand Down Expand Up @@ -86,6 +88,28 @@ describe('resolveCompositionPlan', () => {
expect(plan!.hops.map((h) => h.executor)).toEqual(['fromPdf', 'bridge']);
});

it('composes csv -> pdf through ods (bridge then toPdf), since csv has no layout engine of its own', () => {
const plan = resolveCompositionPlan('csv', 'pdf');
expect(plan).toBeDefined();
expect(plan!.hops.map((h) => h.executor)).toEqual(['bridge', 'toPdf']);
expect(plan!.hops[0]!.from).toBe('csv');
expect(plan!.hops[0]!.to).toBe('ods');
expect(plan!.hops[1]!.from).toBe('ods');
expect(plan!.hops[1]!.to).toBe('pdf');
});

it('composes pdf -> csv through ods (fromPdf then bridge)', () => {
const plan = resolveCompositionPlan('pdf', 'csv');
expect(plan).toBeDefined();
expect(plan!.hops.map((h) => h.executor)).toEqual(['fromPdf', 'bridge']);
});

it('composes csv -> markdown through ods and pdf (three hops), mirroring the xlsx -> markdown last-resort route', () => {
const plan = resolveCompositionPlan('csv', 'markdown');
expect(plan).toBeDefined();
expect(plan!.hops).toHaveLength(3);
});

it('composes xlsx -> markdown through ods and pdf (three hops), the lossiest route in the package', () => {
const plan = resolveCompositionPlan('xlsx', 'markdown');
expect(plan).toBeDefined();
Expand Down
4 changes: 3 additions & 1 deletion src/convert/capability.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DocumentFormat } from './port';

// This module models the real ContentDocument-variant compatibility this family already has (wordprocessing = {docx, odt, markdown}, presentation = {pptx, odp}, spreadsheet = {xlsx, ods}, drawing = {odg alone}) plus which nodes have a direct layout-engine path to/from LayoutDocument (FORMAT_CAPABILITIES below). The composition engine (src/convert/composition.ts) consumes FORMAT_CAPABILITIES' variant declarations to build its composition graph, and UnsupportedConversionError is thrown by convertDocument/local.ts for any pair the pathfinder cannot route. The former DIRECT_EDGES list and resolveConversionPath resolver have been superseded by the pathfinder (resolveCompositionPlan in composition.ts), which derives every resolvable pair from the same registry data.
// This module models the real ContentDocument-variant compatibility this family already has (wordprocessing = {docx, odt, markdown}, presentation = {pptx, odp}, spreadsheet = {xlsx, ods, csv}, drawing = {odg alone}) plus which nodes have a direct layout-engine path to/from LayoutDocument (FORMAT_CAPABILITIES below). The composition engine (src/convert/composition.ts) consumes FORMAT_CAPABILITIES' variant declarations to build its composition graph, and UnsupportedConversionError is thrown by convertDocument/local.ts for any pair the pathfinder cannot route. The former DIRECT_EDGES list and resolveConversionPath resolver have been superseded by the pathfinder (resolveCompositionPlan in composition.ts), which derives every resolvable pair from the same registry data.

// All five of document-schema.js's own ContentDocument kinds. 'formula' is a genuine member rather than a forward-looking one: readOdfFormulaContent produces a real `{kind:'formula', ...}` ContentDocument and odfToPdf consumes one, so `odf` below models it. Unlike the other four, it is a variant of exactly ONE format -- there is no second 'formula'-variant format to bridge it to, which is why a shared variant does not by itself imply a bridge edge exists.
export type ContentVariant = 'wordprocessing' | 'presentation' | 'spreadsheet' | 'drawing' | 'formula';
Expand All @@ -21,6 +21,8 @@ export const FORMAT_CAPABILITIES: Readonly<Record<DocumentFormat, FormatCapabili
ods: { format: 'ods', variant: 'spreadsheet', hasLayoutPath: true },
// xlsx shares the spreadsheet ContentDocument variant with ods (readXlsxContent/buildXlsxPackage, both from ooxml.js) but has no layout-engine path of its own -- there is no convertSpreadsheetToLayout-equivalent xlsx entry point, only ods's. hasLayoutPath stays false: the composition engine routes xlsx <-> pdf through the ods bridge + ods's own layout engine rather than being a genuine ContentDocument -> LayoutDocument pipeline of xlsx's own.
xlsx: { format: 'xlsx', variant: 'spreadsheet', hasLayoutPath: false },
// csv shares the spreadsheet variant with xlsx/ods (readCsvContent/buildCsvText, src/csv/) and follows xlsx's routing exactly: plain text carries no layout of its own, so csv <-> pdf goes through the ods bridge + ods's layout engine. TSV is this same member with { delimiter: '\t' }, not a separate format -- see port.ts's own csv comment.
csv: { format: 'csv', variant: 'spreadsheet', hasLayoutPath: false },
odg: { format: 'odg', variant: 'drawing', hasLayoutPath: true },
// odf reads into the 'formula' ContentDocument variant (readOdfFormulaContent), but hasLayoutPath stays false: odfToPdf renders its formula through writePdf's own separate formula positioning rather than a ContentDocument -> LayoutDocument layout engine, and there is no reverse pdf -> odf at all (see odfToPdf's own module comment in convert.ts).
odf: { format: 'odf', variant: 'formula', hasLayoutPath: false },
Expand Down
97 changes: 96 additions & 1 deletion src/convert/codec.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { decodePackage as decodeOoxmlPackage, readXlsxContent } from 'ooxml.js';
import { z } from 'zod';
import { describe, expect, it } from 'vitest';
import { decodeCsvText, encodeCsvText } from '../csv/text';
import { parseCsvRecords } from '../csv/records';
import { createDocx, openDocx } from '../edit/docx/editor';
import { openOdg } from '../edit/odg/editor';
import { openOdp } from '../edit/odp/editor';
Expand All @@ -14,7 +16,7 @@ import { minimalOdpBytes } from '../test-support/odp';
import { gridOdsBytes } from '../test-support/ods';
import { minimalOdtBytes } from '../test-support/odt';
import { odsToXlsx } from './convert';
import { docxPdfCodec, markdownDocxCodec, markdownOdtCodec, markdownPdfCodec, odgPdfCodec, odpPdfCodec, odsPdfCodec, odtPdfCodec, pptxPdfCodec, xlsxPdfCodec } from './codec';
import { csvMarkdownCodec, csvPdfCodec, docxPdfCodec, markdownDocxCodec, markdownOdtCodec, markdownPdfCodec, odgPdfCodec, odpPdfCodec, odsCsvCodec, odsPdfCodec, odtPdfCodec, pptxPdfCodec, xlsxCsvCodec, xlsxPdfCodec } from './codec';

function pdfHeader(bytes: Uint8Array<ArrayBuffer>): string {
return new TextDecoder('latin1').decode(bytes.subarray(0, 5));
Expand Down Expand Up @@ -239,6 +241,99 @@ describe('markdownPdfCodec', () => {
});
});

// csvToPdf composes the csv -> ods bridge with ods -> pdf internally (csv has no layout engine of its own, exactly like xlsxPdfCodec above), and pdfToCsv composes pdf -> ods -> csv -- so this pair carries the same stacked-reconstruction caveat as xlsxPdfCodec, with csv read's heuristic re-typing on top.
describe('csvPdfCodec', () => {
it('z.decode produces valid PDF bytes from csv bytes', () => {
const pdfBytes = z.decode(csvPdfCodec, encodeCsvText('Name,Amount\nWidget,42.5\n'));
expect(pdfHeader(pdfBytes)).toBe('%PDF-');
});

it('z.encode then z.decode round-trips text content, like csvToPdf/pdfToCsv', () => {
const pdfBytes = z.decode(csvPdfCodec, encodeCsvText('Name,Amount\nWidget,42.5\n'));
const csvBytes = z.encode(csvPdfCodec, pdfBytes);
const records = parseCsvRecords(decodeCsvText(csvBytes));
expect(records[0]).toEqual(['Name', 'Amount']);
expect(records[1]?.[0]).toBe('Widget');
});

it('rejects decode input with malformed UTF-8 before ever reaching csvToPdf', () => {
expect(() => z.decode(csvPdfCodec, new Uint8Array([0xff, 0xfe, 0x00]))).toThrow(z.core.$ZodError);
});

it('rejects encode input with no %PDF- header before ever reaching pdfToCsv', () => {
expect(() => z.encode(csvPdfCodec, new TextEncoder().encode('not a pdf'))).toThrow(z.core.$ZodError);
});
});

// The same-variant spreadsheet bridges: direct ContentDocument pivot copies with no layout engine and no reconstruction, exactly like odsXlsxCodec. The csv boundary is displayText-only -- a typed ods/xlsx cell re-reads as whatever inferCellValue re-types its printed text as on the way back through the encode side.
describe('odsCsvCodec', () => {
it('z.decode produces csv text carrying every rendered cell of the ods fixture', () => {
const csvBytes = z.decode(odsCsvCodec, gridOdsBytes());
const records = parseCsvRecords(decodeCsvText(csvBytes));
expect(records[0]).toEqual(['Alpha', 'Beta', 'Gamma']);
expect(records[1]).toEqual(['One', 'Two', 'Three']);
});

it('z.encode then z.decode round-trips the parsed records, like csvToOds/odsToCsv', () => {
const odsBytes = z.encode(odsCsvCodec, encodeCsvText('Name,Amount\nWidget,42.5\n'));
const csvBytes = z.decode(odsCsvCodec, odsBytes);
expect(parseCsvRecords(decodeCsvText(csvBytes))).toEqual([['Name', 'Amount'], ['Widget', '42.5']]);
});

it('rejects decode input whose first zip entry is not a stored ods mimetype part before ever reaching odsToCsv', () => {
expect(() => z.decode(odsCsvCodec, new TextEncoder().encode('not an ods'))).toThrow(z.core.$ZodError);
});

it('rejects encode input with malformed UTF-8 before ever reaching csvToOds', () => {
expect(() => z.encode(odsCsvCodec, new Uint8Array([0xff, 0xfe, 0x00]))).toThrow(z.core.$ZodError);
});
});

describe('xlsxCsvCodec', () => {
it('z.decode produces csv text carrying every rendered cell of the xlsx fixture', () => {
const csvBytes = z.decode(xlsxCsvCodec, odsToXlsx(gridOdsBytes()));
const records = parseCsvRecords(decodeCsvText(csvBytes));
expect(records[0]).toEqual(['Alpha', 'Beta', 'Gamma']);
});

it('z.encode then z.decode round-trips the parsed records, like csvToXlsx/xlsxToCsv', () => {
const xlsxBytes = z.encode(xlsxCsvCodec, encodeCsvText('Name,Amount\nWidget,42.5\n'));
const csvBytes = z.decode(xlsxCsvCodec, xlsxBytes);
expect(parseCsvRecords(decodeCsvText(csvBytes))).toEqual([['Name', 'Amount'], ['Widget', '42.5']]);
});

it('rejects decode input with no ZIP local-file-header before ever reaching xlsxToCsv', () => {
expect(() => z.decode(xlsxCsvCodec, new TextEncoder().encode('not an xlsx'))).toThrow(z.core.$ZodError);
});

it('rejects encode input with malformed UTF-8 before ever reaching csvToXlsx', () => {
expect(() => z.encode(xlsxCsvCodec, new Uint8Array([0xff, 0xfe, 0x00]))).toThrow(z.core.$ZodError);
});
});

// The pdf-composed last-resort pair, mirroring xlsxMarkdownCodec's own shape: neither direction is remotely round-trip-lossless (spreadsheet render stacked on markdown reconstruction and back), so the assertions are structural -- valid output of the target schema carrying real text on the decode side.
describe('csvMarkdownCodec', () => {
it('z.decode produces markdown carrying the rendered cell text, like csvToMarkdown', () => {
const markdownBytes = z.decode(csvMarkdownCodec, encodeCsvText('Name,Amount\nWidget,42.5\n'));
const text = decodeMarkdownText(markdownBytes);
expect(text).toContain('Name');
expect(text).toContain('Widget');
});

it('z.encode produces csv bytes that parse as well-formed RFC 4180 records, like markdownToCsv', () => {
const csvBytes = z.encode(csvMarkdownCodec, encodeMarkdownText('| A | B |\n| --- | --- |\n| one | two |\n'));
expect(parseCsvRecords(decodeCsvText(csvBytes)).length).toBeGreaterThan(0);
});

it('rejects decode input with malformed UTF-8 before ever reaching csvToMarkdown', () => {
expect(() => z.decode(csvMarkdownCodec, new Uint8Array([0xff, 0xfe, 0x00]))).toThrow(z.core.$ZodError);
});

it('rejects encode input with malformed UTF-8 before ever reaching markdownToCsv', () => {
expect(() => z.encode(csvMarkdownCodec, new Uint8Array([0xff, 0xfe, 0x00]))).toThrow(z.core.$ZodError);
});
});

describe('markdownDocxCodec', () => {
it('z.decode produces valid docx bytes from markdown bytes', () => {
const docxBytes = z.decode(markdownDocxCodec, encodeMarkdownText(richMarkdownText()));
Expand Down
Loading
Loading