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
72 changes: 43 additions & 29 deletions README.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/codecs/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { buildMarkdownText } from '../markdown/write';
import { decodeCsvText, encodeCsvText } from '../csv/text';
import { readCsvContent } from '../csv/read';
import { buildCsvText } from '../csv/write';
import { decodeSvgText, encodeSvgText } from '../svg/text';
import { readSvgContent } from '../svg/read';
import { buildSvgText } from '../svg/write';
import { readOdfFormulaContent } from '../odf/formula/read';
import { readOdgContent } from '../odf/odg/read';
import { readOdpContent } from '../odf/odp/read';
Expand Down Expand Up @@ -126,6 +129,13 @@ export const DOCUMENT_FORMAT_CODECS: Readonly<Record<DocumentFormat, DocumentFor
write: (content) => encodeCsvText(buildCsvText(content)),
},
},
// The svg entry is the csv entry's structural twin: decode straight from bytes to text (no package), read into a drawing ContentDocument, write the reverse. DocumentCodecOptions carries no page/onSvgDiagnostic, so this codec writes page 0 of a drawing with no diagnostic channel -- a caller wanting a different page of a multi-page document or the reader's scope-limit diagnostics uses the named conversions (convert.ts's svgToPdf/odgToSvg and their kin), which thread { page, onSvgDiagnostic } through UnifiedConversionOptions; a multi-page write through THIS codec throws buildSvgText's own SvgMultiPageNotSpecifiedError rather than silently truncating. decodeSvgText is a fatal decoder with no loop of its own, so no separate signal check is needed -- the same reasoning as the csv entry beside it.
svg: {
content: {
read: (bytes) => readSvgContent(decodeSvgText(bytes)),
write: (content) => encodeSvgText(buildSvgText(content)),
},
},
pdf: {
layout: {
read: (bytes, options) => readPdf(requireArrayBufferBytes(bytes), { signal: options?.signal }),
Expand Down
34 changes: 33 additions & 1 deletion src/convert/capability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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', 'csv']));
expect(new Set(byVariant.get('drawing'))).toEqual(new Set(['odg']));
expect(new Set(byVariant.get('drawing'))).toEqual(new Set(['odg', 'svg']));
});

it('marks xlsx and csv as the spreadsheet members with no layout path of their own (ods carries the layout edge)', () => {
Expand All @@ -30,6 +30,11 @@ describe('FORMAT_CAPABILITIES', () => {
expect(FORMAT_CAPABILITIES.ods.hasLayoutPath).toBe(true);
});

it('marks svg as the drawing family\'s plain-text member with its own layout path (a sibling of odg, not an ods-style composed member)', () => {
expect(FORMAT_CAPABILITIES.svg.variant).toBe('drawing');
expect(FORMAT_CAPABILITIES.svg.hasLayoutPath).toBe(true);
});

it('has no undefined-variant format other than pdf and odf reporting a layout path', () => {
for (const capability of Object.values(FORMAT_CAPABILITIES)) {
if (capability.variant === undefined) {
Expand Down Expand Up @@ -104,6 +109,33 @@ describe('resolveCompositionPlan', () => {
expect(plan!.hops.map((h) => h.executor)).toEqual(['fromPdf', 'bridge']);
});

it('routes svg -> pdf as a single toPdf hop, since svg rides the drawing layout engine odg feeds', () => {
const plan = resolveCompositionPlan('svg', 'pdf');
expect(plan).toBeDefined();
expect(plan!.hops).toHaveLength(1);
expect(plan!.hops[0]!.executor).toBe('toPdf');
expect(plan!.hops[0]!.from).toBe('svg');
expect(plan!.hops[0]!.to).toBe('pdf');
});

it('routes pdf -> svg as a single fromPdf hop', () => {
const plan = resolveCompositionPlan('pdf', 'svg');
expect(plan).toBeDefined();
expect(plan!.hops).toHaveLength(1);
expect(plan!.hops[0]!.executor).toBe('fromPdf');
expect(plan!.hops[0]!.from).toBe('pdf');
expect(plan!.hops[0]!.to).toBe('svg');
});

it('routes svg -> odg as a single same-variant bridge hop (the drawing family\'s plain-text member)', () => {
const plan = resolveCompositionPlan('svg', 'odg');
expect(plan).toBeDefined();
expect(plan!.hops).toHaveLength(1);
expect(plan!.hops[0]!.executor).toBe('bridge');
expect(plan!.hops[0]!.from).toBe('svg');
expect(plan!.hops[0]!.to).toBe('odg');
});

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();
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, 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.
// 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, svg}) 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 @@ -24,6 +24,8 @@ export const FORMAT_CAPABILITIES: Readonly<Record<DocumentFormat, FormatCapabili
// 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 },
// svg shares the drawing ContentDocument variant with odg (readSvgContent/buildSvgText, src/svg/) and has a genuine layout-engine edge of its own: svgToPdf feeds the drawing ContentDocument it reads straight into the same convertDrawingToLayout engine odgToPdf already uses, so hasLayoutPath is true -- unlike csv's text-only entry, plain SVG text still describes real page geometry (a root viewBox is a page size), and the drawing layout engine renders it.
svg: { format: 'svg', 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 },
// markdown shares the wordprocessing variant with docx/odt (readMarkdownContent produces the identical WordprocessingContentDocument shape -- see convert.ts's own top-of-file comment) and has a genuine layout-engine edge of its own (markdownToPdf/pdfToMarkdown both reuse convertWordprocessingToLayout/reconstructWordprocessing unmodified), unlike xlsx above.
Expand Down
18 changes: 17 additions & 1 deletion src/convert/codec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { CsvBytesSchema, DocxBytesSchema, MarkdownBytesSchema, OdgBytesSchema, OdpBytesSchema, OdsBytesSchema, OdtBytesSchema, PdfBytesSchema, PptxBytesSchema, XlsxBytesSchema } from '../model/bytes';
import { CsvBytesSchema, DocxBytesSchema, MarkdownBytesSchema, OdgBytesSchema, OdpBytesSchema, OdsBytesSchema, OdtBytesSchema, PdfBytesSchema, PptxBytesSchema, SvgBytesSchema, XlsxBytesSchema } from '../model/bytes';
import {
csvToMarkdown,
csvToOds,
Expand All @@ -13,6 +13,7 @@ import {
markdownToOdt,
markdownToPdf,
odgToPdf,
odgToSvg,
odpToPdf,
odpToPptx,
odsToCsv,
Expand All @@ -29,9 +30,12 @@ import {
pdfToOds,
pdfToOdt,
pdfToPptx,
pdfToSvg,
pdfToXlsx,
pptxToOdp,
pptxToPdf,
svgToOdg,
svgToPdf,
xlsxToCsv,
xlsxToOds,
xlsxToPdf,
Expand Down Expand Up @@ -82,6 +86,12 @@ export const markdownPdfCodec = z.codec(MarkdownBytesSchema, PdfBytesSchema, {
encode: (pdfBytes) => pdfToMarkdown(pdfBytes),
});

// svg bytes <-> PDF bytes: a schema-validated z.codec() pair over svgToPdf/pdfToSvg (convert.ts), which -- like markdownPdfCodec above and unlike csvPdfCodec/xlsxPdfCodec below -- DOES lay its source out directly (svgToPdf feeds the drawing ContentDocument readSvgContent produced into the same convertDrawingToLayout engine odgPdfCodec feeds). Decode-side lossiness is the reader's documented scope limits (text, gradients, filters, images, CSS, and <use> degrade under diagnostics, never silently); encode-side is reconstructDrawing's near-1:1 mapping with the kind-narrowing every pdf-to-content recovery performs. Still the no-options form only: svgToPdf accepts onSvgDiagnostic and pdfToSvg accepts page/onSvgDiagnostic, neither of which z.codec()'s fixed decode(input)/encode(output) signature has room for -- and the encode leg WILL throw SvgMultiPageNotSpecifiedError on a multi-page PDF, since page selection is exactly such an option.
export const svgPdfCodec = z.codec(SvgBytesSchema, PdfBytesSchema, {
decode: (svgBytes) => svgToPdf(svgBytes),
encode: (pdfBytes) => pdfToSvg(pdfBytes),
});

// odt bytes <-> docx bytes, odp bytes <-> pptx bytes, ods bytes <-> xlsx bytes, markdown bytes <-> docx bytes, and markdown bytes <-> odt bytes: schema-validated z.codec() pairs over the cross-format bridge functions (convert.ts), which unlike every PDF-pivot codec above bypass PDF entirely -- see convert.ts's own module comment on that section. The blanket "not round-trip-lossless doesn't apply to these" claim this comment used to make here is genuinely false for the two markdown pairs below, and is now stated precisely rather than glossed over: odtDocxCodec/odpPptxCodec/odsXlsxCodec decode/encode a direct ContentDocument pivot copy with no layout or reconstruction step, so those three really do carry no PDF-pivot-style lossiness of their own -- but markdownDocxCodec/markdownOdtCodec still lose everything CommonMark/GFM itself cannot represent (colour, font family/size, explicit alignment, page geometry) on the DECODE side (markdown -> docx/odt), simply because that information was never in the markdown source to begin with; ENCODE (docx/odt -> markdown) then discards it a second time on the way back down, same as it always would. That is not the PDF-pivot's geometry-based reconstruction lossiness -- there is still no layout engine and no geometric guessing anywhere in either direction -- but it is real, format-boundary lossiness all the same, and pretending otherwise here would misdescribe what markdownDocxCodec/markdownOdtCodec actually preserve. Still the no-options form only, for the same reason as above: odtToDocx et al. (and now markdownToDocx et al.) accept a signal option z.codec()'s fixed decode(input)/encode(output) signature has no room for.
export const odtDocxCodec = z.codec(OdtBytesSchema, DocxBytesSchema, {
decode: (odtBytes) => odtToDocx(odtBytes),
Expand Down Expand Up @@ -131,6 +141,12 @@ export const xlsxCsvCodec = z.codec(XlsxBytesSchema, CsvBytesSchema, {
encode: (csvBytes) => csvToXlsx(csvBytes),
});

// odg bytes <-> svg bytes: a schema-validated z.codec() pair over the same-variant drawing bridge (convert.ts) -- a direct ContentDocument pivot copy with no layout engine and no reconstruction, exactly like odsXlsxCodec above. Both sides speak the identical six-primitive ContentVector vocabulary, so geometry crosses losslessly; the one boundary is paint defaults (an SVG shape with no fill attribute reads as black-filled, the SVG specification's own default). The no-options encode leg throws SvgMultiPageNotSpecifiedError on a multi-page odg rather than silently truncating -- the identical contract the registry codec (src/codecs/registry.ts) documents for its own svg write.
export const odgSvgCodec = z.codec(OdgBytesSchema, SvgBytesSchema, {
decode: (odgBytes) => odgToSvg(odgBytes),
encode: (svgBytes) => svgToOdg(svgBytes),
});

// csv bytes <-> markdown bytes: the no-options form over the two pdf-composed bridge functions (convert.ts), routing csv -> ods -> pdf -> markdown and markdown -> pdf -> ods -> csv. Lossy in the same stacked way as xlsxMarkdownCodec above -- the spreadsheet render and the markdown reconstruction each add their own loss -- with csv read's heuristic re-typing on top on the decode side.
export const csvMarkdownCodec = z.codec(CsvBytesSchema, MarkdownBytesSchema, {
decode: (csvBytes) => csvToMarkdown(csvBytes),
Expand Down
Loading