@bendyline/squisq-formats 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +180 -43
  2. package/dist/{chunk-CRTC6DEX.js → chunk-46FMDOWZ.js} +2 -2
  3. package/dist/{chunk-ON232HJR.js → chunk-5LSSWZPU.js} +2 -2
  4. package/dist/{chunk-APSIAEXL.js → chunk-D7IFPWNZ.js} +87 -43
  5. package/dist/chunk-D7IFPWNZ.js.map +1 -0
  6. package/dist/chunk-DQAZR57U.js +33 -0
  7. package/dist/chunk-DQAZR57U.js.map +1 -0
  8. package/dist/chunk-EHLGMSTE.js +273 -0
  9. package/dist/chunk-EHLGMSTE.js.map +1 -0
  10. package/dist/{chunk-A3FHLTY5.js → chunk-MJGRI6XR.js} +6 -19
  11. package/dist/chunk-MJGRI6XR.js.map +1 -0
  12. package/dist/{chunk-JBNVE2GV.js → chunk-RFAPOKHJ.js} +9 -2
  13. package/dist/chunk-RFAPOKHJ.js.map +1 -0
  14. package/dist/chunk-RTS5XBZ7.js +526 -0
  15. package/dist/chunk-RTS5XBZ7.js.map +1 -0
  16. package/dist/{chunk-FMOIGA5T.js → chunk-SHLPSADL.js} +178 -5
  17. package/dist/chunk-SHLPSADL.js.map +1 -0
  18. package/dist/chunk-SSUPBUF5.js +15 -0
  19. package/dist/chunk-SSUPBUF5.js.map +1 -0
  20. package/dist/{chunk-BJQIJ4S4.js → chunk-WWBIKRNP.js} +17 -41
  21. package/dist/chunk-WWBIKRNP.js.map +1 -0
  22. package/dist/{chunk-I2GFTEOV.js → chunk-YESS7VY3.js} +77 -15
  23. package/dist/{chunk-I2GFTEOV.js.map → chunk-YESS7VY3.js.map} +1 -1
  24. package/dist/csv/index.d.ts +14 -1
  25. package/dist/csv/index.js +1 -1
  26. package/dist/docx/index.js +4 -3
  27. package/dist/epub/index.js +3 -2
  28. package/dist/html/index.d.ts +3 -0
  29. package/dist/html/index.js +3 -2
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +26 -11
  32. package/dist/ooxml/index.d.ts +5 -1
  33. package/dist/ooxml/index.js +16 -12
  34. package/dist/pdf/index.js +2 -1
  35. package/dist/pptx/index.d.ts +18 -3
  36. package/dist/pptx/index.js +7 -4
  37. package/dist/registry/index.d.ts +167 -0
  38. package/dist/registry/index.js +17 -0
  39. package/dist/registry/index.js.map +1 -0
  40. package/dist/xlsx/index.d.ts +44 -14
  41. package/dist/xlsx/index.js +4 -2
  42. package/package.json +7 -2
  43. package/src/__tests__/convert.test.ts +186 -0
  44. package/src/__tests__/csvImport.test.ts +34 -1
  45. package/src/__tests__/formatRegistry.test.ts +174 -0
  46. package/src/__tests__/lossyWarnings.test.ts +105 -0
  47. package/src/__tests__/pdfImport.test.ts +95 -1
  48. package/src/__tests__/pptxImport.test.ts +46 -1
  49. package/src/__tests__/roundTripAssets.test.ts +50 -0
  50. package/src/__tests__/roundTripMatrix.test.ts +10 -4
  51. package/src/__tests__/sharedContainer.test.ts +41 -0
  52. package/src/__tests__/sharedImages.test.ts +61 -0
  53. package/src/__tests__/xlsxExport.test.ts +164 -0
  54. package/src/csv/index.ts +25 -2
  55. package/src/docx/import.ts +6 -37
  56. package/src/html/imageUtils.ts +5 -19
  57. package/src/index.ts +29 -5
  58. package/src/ooxml/index.ts +3 -0
  59. package/src/ooxml/namespaces.ts +10 -0
  60. package/src/pdf/import.ts +145 -50
  61. package/src/pptx/import.ts +127 -10
  62. package/src/pptx/index.ts +1 -1
  63. package/src/registry/convert.ts +318 -0
  64. package/src/registry/defaultFormats.ts +323 -0
  65. package/src/registry/errors.ts +46 -0
  66. package/src/registry/index.ts +38 -0
  67. package/src/registry/registry.ts +48 -0
  68. package/src/registry/types.ts +107 -0
  69. package/src/shared/container.ts +28 -0
  70. package/src/shared/images.ts +44 -0
  71. package/src/xlsx/export.ts +253 -0
  72. package/src/xlsx/index.ts +10 -36
  73. package/dist/chunk-A3FHLTY5.js.map +0 -1
  74. package/dist/chunk-APSIAEXL.js.map +0 -1
  75. package/dist/chunk-BJQIJ4S4.js.map +0 -1
  76. package/dist/chunk-FMOIGA5T.js.map +0 -1
  77. package/dist/chunk-JBNVE2GV.js.map +0 -1
  78. package/dist/chunk-L53YIGYS.js +0 -141
  79. package/dist/chunk-L53YIGYS.js.map +0 -1
  80. package/dist/chunk-QSE7EWE7.js +0 -181
  81. package/dist/chunk-QSE7EWE7.js.map +0 -1
  82. /package/dist/{chunk-CRTC6DEX.js.map → chunk-46FMDOWZ.js.map} +0 -0
  83. /package/dist/{chunk-ON232HJR.js.map → chunk-5LSSWZPU.js.map} +0 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Format registry + programmatic `convert()` — @bendyline/squisq-formats/registry
3
+ *
4
+ * A small registry maps format ids (`md`, `docx`, `pdf`, `pptx`, `xlsx`, `csv`,
5
+ * `html`, `htmlzip`, `epub`, `dbk`) to `FormatDefinition`s that know how to
6
+ * import to / export from squisq's markdown + Doc model. `convert()` is the
7
+ * front door: give it a source and a target format id and it returns the
8
+ * encoded bytes plus a suggested filename.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { convert } from '@bendyline/squisq-formats/registry';
13
+ *
14
+ * const result = await convert(
15
+ * { kind: 'markdown', markdown: '# Hello', baseName: 'greeting' },
16
+ * 'docx',
17
+ * );
18
+ * // result.bytes, result.mimeType, result.suggestedFilename ('greeting.docx')
19
+ * ```
20
+ */
21
+
22
+ export type {
23
+ FormatId,
24
+ ConversionResult,
25
+ NormalizedInput,
26
+ ConvertOptions,
27
+ FormatDefinition,
28
+ FormatRegistry,
29
+ ConvertSource,
30
+ } from './types.js';
31
+ export { BUILTIN_FORMAT_IDS } from './types.js';
32
+
33
+ export { ConversionError } from './errors.js';
34
+ export type { ConversionErrorCode, ConversionErrorOptions } from './errors.js';
35
+
36
+ export { createRegistry, defaultRegistry } from './registry.js';
37
+ export { defaultFormats } from './defaultFormats.js';
38
+ export { convert } from './convert.js';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Format registry implementation.
3
+ *
4
+ * `createRegistry()` returns an empty, mutable registry (last-write-wins by id);
5
+ * `defaultRegistry()` returns one preloaded with every built-in format.
6
+ */
7
+
8
+ import type { FormatDefinition, FormatId, FormatRegistry } from './types.js';
9
+ import { defaultFormats } from './defaultFormats.js';
10
+
11
+ /** Normalize an extension to lowercase, without a leading dot. */
12
+ function normalizeExt(ext: string): string {
13
+ return ext.replace(/^\.+/, '').toLowerCase();
14
+ }
15
+
16
+ /** Create an empty registry. Registering an existing id overwrites it. */
17
+ export function createRegistry(): FormatRegistry {
18
+ const byId = new Map<FormatId, FormatDefinition>();
19
+
20
+ return {
21
+ register(def: FormatDefinition): void {
22
+ byId.set(def.id, def);
23
+ },
24
+ get(id: FormatId): FormatDefinition | undefined {
25
+ return byId.get(id);
26
+ },
27
+ byExtension(ext: string): FormatDefinition | undefined {
28
+ const target = normalizeExt(ext);
29
+ if (!target) return undefined;
30
+ for (const def of byId.values()) {
31
+ if (def.extensions.some((e) => normalizeExt(e) === target)) return def;
32
+ }
33
+ return undefined;
34
+ },
35
+ list(): FormatDefinition[] {
36
+ return [...byId.values()];
37
+ },
38
+ };
39
+ }
40
+
41
+ /** Create a registry preloaded with all built-in formats. */
42
+ export function defaultRegistry(): FormatRegistry {
43
+ const registry = createRegistry();
44
+ for (const def of defaultFormats()) {
45
+ registry.register(def);
46
+ }
47
+ return registry;
48
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Format registry — shared types.
3
+ *
4
+ * The registry is the programmatic heart of `convert()`: it maps a small set of
5
+ * format ids (`md`, `docx`, `pdf`, …) to `FormatDefinition`s, each of which
6
+ * knows how to import to / export from squisq's markdown + Doc model. Every
7
+ * converter module is loaded lazily via `import()` inside the definition
8
+ * methods, so pulling in the registry never eagerly bundles heavy converters.
9
+ */
10
+
11
+ import type { Doc } from '@bendyline/squisq/schemas';
12
+ import type { MarkdownDocument } from '@bendyline/squisq/markdown';
13
+ import type { ContentContainer } from '@bendyline/squisq/storage';
14
+
15
+ /** A format identifier (e.g. `'docx'`). Strings so hosts can register their own. */
16
+ export type FormatId = string;
17
+
18
+ /** The built-in formats the default registry ships with. */
19
+ export const BUILTIN_FORMAT_IDS = [
20
+ 'md',
21
+ 'docx',
22
+ 'pdf',
23
+ 'pptx',
24
+ 'xlsx',
25
+ 'csv',
26
+ 'html',
27
+ 'htmlzip',
28
+ 'epub',
29
+ 'dbk',
30
+ ] as const;
31
+
32
+ /** The bytes + metadata produced by a successful export. */
33
+ export interface ConversionResult {
34
+ /** Encoded output bytes. */
35
+ bytes: Uint8Array;
36
+ /** MIME type of the output. */
37
+ mimeType: string;
38
+ /** A sensible download filename (`<baseName>.<ext>`). */
39
+ suggestedFilename: string;
40
+ /** Non-fatal notes accumulated during conversion (may be empty). */
41
+ warnings: string[];
42
+ }
43
+
44
+ /**
45
+ * A source normalized into every shape an exporter might need. `doc` is always
46
+ * present; `markdownDoc` is present when the source was markdown-shaped (an
47
+ * exporter that wants markdown but finds none derives it from `doc`).
48
+ */
49
+ export interface NormalizedInput {
50
+ doc: Doc;
51
+ markdownDoc?: MarkdownDocument;
52
+ container: ContentContainer;
53
+ baseName: string;
54
+ }
55
+
56
+ /** Options threaded through `convert()` and into every format method. */
57
+ export interface ConvertOptions {
58
+ /** Registry to resolve formats against. Defaults to `defaultRegistry()`. */
59
+ registry?: FormatRegistry;
60
+ /** Explicit source format id (skips extension/byte sniffing). */
61
+ from?: FormatId;
62
+ /** Theme id to apply to the exported document. */
63
+ themeId?: string;
64
+ /** Transform style id to apply before export. */
65
+ transformStyle?: string;
66
+ /** Content-aware auto-templating when deriving a Doc from markdown. */
67
+ autoTemplates?: boolean;
68
+ /** Title hint for exporters that support one (epub, html). */
69
+ title?: string;
70
+ /** Lazily resolve the standalone player IIFE bundle (required for HTML export). */
71
+ resolvePlayerScript?: () => Promise<string>;
72
+ /** Per-format escape hatch for extra options. */
73
+ formatOptions?: Record<FormatId, Record<string, unknown>>;
74
+ }
75
+
76
+ /** Describes how a single format imports to / exports from the squisq model. */
77
+ export interface FormatDefinition {
78
+ id: FormatId;
79
+ label: string;
80
+ mimeType: string;
81
+ extensions: readonly string[];
82
+ /** Import raw bytes to a MarkdownDocument. */
83
+ importDoc?(data: ArrayBuffer, options: ConvertOptions): Promise<MarkdownDocument>;
84
+ /** Import raw bytes to a ContentContainer (markdown + extracted media). */
85
+ importContainer?(data: ArrayBuffer, options: ConvertOptions): Promise<ContentContainer>;
86
+ /** Export a normalized input to bytes. */
87
+ exportDoc?(input: NormalizedInput, options: ConvertOptions): Promise<ConversionResult>;
88
+ }
89
+
90
+ /** A mutable collection of format definitions keyed by id. */
91
+ export interface FormatRegistry {
92
+ register(def: FormatDefinition): void;
93
+ get(id: FormatId): FormatDefinition | undefined;
94
+ byExtension(ext: string): FormatDefinition | undefined;
95
+ list(): FormatDefinition[];
96
+ }
97
+
98
+ /** The three shapes `convert()` accepts as a source. */
99
+ export type ConvertSource =
100
+ | { kind: 'bytes'; data: ArrayBuffer | Uint8Array; filename?: string }
101
+ | {
102
+ kind: 'markdown';
103
+ markdown: string | MarkdownDocument;
104
+ container?: ContentContainer;
105
+ baseName?: string;
106
+ }
107
+ | { kind: 'doc'; doc: Doc; container?: ContentContainer; baseName?: string };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared tail for the container-producing importers.
3
+ *
4
+ * `docxToContainer`, `pptxToContainer`, and `pdfToContainer` all end the same
5
+ * way: create a `MemoryContentContainer`, write the primary markdown document,
6
+ * then write each extracted image under its path with its MIME type. This
7
+ * factors that identical epilogue into one place.
8
+ */
9
+
10
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
11
+ import type { ContentContainer } from '@bendyline/squisq/storage';
12
+
13
+ /**
14
+ * Assemble a {@link ContentContainer} from a markdown document plus a set of
15
+ * media files. `images` is any iterable of `[path, { data, mimeType }]` pairs
16
+ * (a `Map` from the importers' extraction context satisfies this directly).
17
+ */
18
+ export async function buildContainer(
19
+ markdown: string,
20
+ images: Iterable<readonly [string, { data: ArrayBuffer; mimeType: string }]>,
21
+ ): Promise<ContentContainer> {
22
+ const container = new MemoryContentContainer();
23
+ await container.writeDocument(markdown);
24
+ for (const [path, { data, mimeType }] of images) {
25
+ await container.writeFile(path, new Uint8Array(data), mimeType);
26
+ }
27
+ return container;
28
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared extension → MIME-type mapping for embedded media.
3
+ *
4
+ * Previously triplicated with subtle drift: the docx and pptx importers each
5
+ * carried a byte-identical dotted-key map (png/jpg/.../emf/wmf), while the html
6
+ * exporter's `inferMimeType` used no-dot keys and a different key set (added
7
+ * audio/video/ico/avif, omitted tiff/emf/wmf). This module is the single union
8
+ * of all of them, so a newly-supported type only has to be added once.
9
+ */
10
+
11
+ /** Union of every extension→MIME entry the format modules need. */
12
+ const EXT_TO_MIME: Record<string, string> = {
13
+ // Raster / vector images
14
+ png: 'image/png',
15
+ jpg: 'image/jpeg',
16
+ jpeg: 'image/jpeg',
17
+ gif: 'image/gif',
18
+ webp: 'image/webp',
19
+ bmp: 'image/bmp',
20
+ svg: 'image/svg+xml',
21
+ tiff: 'image/tiff',
22
+ tif: 'image/tiff',
23
+ ico: 'image/x-icon',
24
+ avif: 'image/avif',
25
+ // Office vector formats (docx/pptx embedded metafiles)
26
+ emf: 'image/emf',
27
+ wmf: 'image/wmf',
28
+ // Audio / video (html export inlines these as data URIs)
29
+ mp3: 'audio/mpeg',
30
+ wav: 'audio/wav',
31
+ ogg: 'audio/ogg',
32
+ mp4: 'video/mp4',
33
+ webm: 'video/webm',
34
+ };
35
+
36
+ /**
37
+ * Map a file extension to its MIME type. Tolerates a leading dot and any
38
+ * casing (`.PNG`, `png`, `.png` all resolve). Returns
39
+ * `application/octet-stream` for unknown extensions.
40
+ */
41
+ export function extToMime(ext: string): string {
42
+ const key = ext.replace(/^\./, '').toLowerCase();
43
+ return EXT_TO_MIME[key] ?? 'application/octet-stream';
44
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * XLSX export — MarkdownDocument → SpreadsheetML (.xlsx).
3
+ *
4
+ * Tables-only fidelity (honestly documented): every `table` node in the
5
+ * markdown AST becomes one worksheet; all other content (headings, prose,
6
+ * lists, images, …) is dropped except that the nearest preceding heading is
7
+ * used to name the sheet. This mirrors the import side (`xlsxToMarkdownDoc`),
8
+ * which turns each worksheet grid back into a markdown table.
9
+ *
10
+ * Cells are emitted as inline strings (`t="inlineStr"`) so no sharedStrings
11
+ * part is needed, except values matching a plain-number pattern, which are
12
+ * emitted as numeric cells. The package is assembled with the shared ooxml/
13
+ * writer (auto-generates `[Content_Types].xml` + `_rels`), so only the
14
+ * SpreadsheetML-specific parts (workbook, worksheets, styles) are written here.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { parseMarkdown } from '@bendyline/squisq/markdown';
19
+ * import { markdownDocToXlsx } from '@bendyline/squisq-formats/xlsx';
20
+ *
21
+ * const md = parseMarkdown('# Metrics\n\n| A | B |\n| - | - |\n| 1 | 2 |');
22
+ * const buffer = await markdownDocToXlsx(md);
23
+ * ```
24
+ */
25
+
26
+ import type { Doc } from '@bendyline/squisq/schemas';
27
+ import { docToMarkdown } from '@bendyline/squisq/doc';
28
+ import type {
29
+ MarkdownDocument,
30
+ MarkdownBlockNode,
31
+ MarkdownTable,
32
+ MarkdownTableRow,
33
+ } from '@bendyline/squisq/markdown';
34
+
35
+ import { createPackage } from '../ooxml/writer.js';
36
+ import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
37
+ import { extractPlainText } from '../shared/text.js';
38
+ import {
39
+ NS_SML,
40
+ NS_R,
41
+ REL_OFFICE_DOCUMENT,
42
+ REL_STYLES,
43
+ REL_WORKSHEET,
44
+ CONTENT_TYPE_XLSX_WORKBOOK,
45
+ CONTENT_TYPE_XLSX_WORKSHEET,
46
+ CONTENT_TYPE_XLSX_STYLES,
47
+ } from '../ooxml/namespaces.js';
48
+
49
+ /**
50
+ * Options for XLSX export.
51
+ */
52
+ export interface XlsxExportOptions {
53
+ /** Workbook title (written to core properties). */
54
+ title?: string;
55
+ /** Workbook author (written to core properties). */
56
+ author?: string;
57
+ /** Prefix used for auto-named sheets when no heading precedes a table. Default: "Sheet". */
58
+ sheetNamePrefix?: string;
59
+ }
60
+
61
+ interface SheetData {
62
+ name: string;
63
+ /** Grid of cell text: first row is the header. */
64
+ grid: string[][];
65
+ }
66
+
67
+ /** A plain integer or decimal (optionally negative) — emitted as a numeric cell. */
68
+ const NUMERIC_RE = /^-?\d+(\.\d+)?$/;
69
+
70
+ /**
71
+ * Sanitize a candidate name into a valid, unique Excel sheet name.
72
+ * Strips characters Excel forbids (`[]:*?/\`), caps at 31 chars, and
73
+ * de-duplicates against already-used names by appending 2, 3, …
74
+ */
75
+ function sanitizeSheetName(raw: string, used: Set<string>, fallback: string): string {
76
+ let name = raw.replace(/[[\]:*?/\\]/g, '').trim();
77
+ if (name.length === 0) name = fallback;
78
+ name = name.slice(0, 31);
79
+
80
+ if (!used.has(name)) {
81
+ used.add(name);
82
+ return name;
83
+ }
84
+ // Dedupe: "Name", "Name2", "Name3", … keeping within the 31-char cap.
85
+ for (let n = 2; ; n++) {
86
+ const suffix = String(n);
87
+ const base = name.slice(0, 31 - suffix.length);
88
+ const candidate = `${base}${suffix}`;
89
+ if (!used.has(candidate)) {
90
+ used.add(candidate);
91
+ return candidate;
92
+ }
93
+ }
94
+ }
95
+
96
+ /** Convert a table node into a grid of plain-text cells. */
97
+ function tableToGrid(table: MarkdownTable): string[][] {
98
+ return table.children.map((row: MarkdownTableRow) =>
99
+ row.children.map((cell) => extractPlainText(cell.children)),
100
+ );
101
+ }
102
+
103
+ /** Walk the document, pairing each table with the nearest preceding heading. */
104
+ function collectSheets(doc: MarkdownDocument, prefix: string): SheetData[] {
105
+ const used = new Set<string>();
106
+ const sheets: SheetData[] = [];
107
+ let pendingHeading: string | null = null;
108
+
109
+ const visit = (nodes: MarkdownBlockNode[]): void => {
110
+ for (const node of nodes) {
111
+ if (node.type === 'heading') {
112
+ pendingHeading = extractPlainText(node.children);
113
+ } else if (node.type === 'table') {
114
+ const fallback = `${prefix}${sheets.length + 1}`;
115
+ const name = sanitizeSheetName(pendingHeading ?? fallback, used, fallback);
116
+ sheets.push({ name, grid: tableToGrid(node) });
117
+ pendingHeading = null;
118
+ }
119
+ }
120
+ };
121
+
122
+ visit(doc.children);
123
+ return sheets;
124
+ }
125
+
126
+ /** Column letters for a zero-based index (0 → "A", 26 → "AA"). */
127
+ function columnLetter(index: number): string {
128
+ let n = index + 1;
129
+ let letters = '';
130
+ while (n > 0) {
131
+ const rem = (n - 1) % 26;
132
+ letters = String.fromCharCode(65 + rem) + letters;
133
+ n = Math.floor((n - 1) / 26);
134
+ }
135
+ return letters;
136
+ }
137
+
138
+ function cellXml(value: string, ref: string): string {
139
+ if (NUMERIC_RE.test(value)) {
140
+ return `<c r="${ref}"><v>${escapeXml(value)}</v></c>`;
141
+ }
142
+ return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(value)}</t></is></c>`;
143
+ }
144
+
145
+ function worksheetXml(grid: string[][]): string {
146
+ const rows: string[] = [];
147
+ for (let r = 0; r < grid.length; r++) {
148
+ const cells = grid[r]!;
149
+ const cellsXml = cells.map((value, c) => cellXml(value, `${columnLetter(c)}${r + 1}`)).join('');
150
+ rows.push(`<row r="${r + 1}">${cellsXml}</row>`);
151
+ }
152
+ return (
153
+ `${xmlDeclaration()}\n` +
154
+ `<worksheet xmlns="${NS_SML}" xmlns:r="${NS_R}">` +
155
+ `<sheetData>${rows.join('')}</sheetData>` +
156
+ `</worksheet>`
157
+ );
158
+ }
159
+
160
+ function workbookXml(sheets: SheetData[]): string {
161
+ const sheetEls = sheets
162
+ .map(
163
+ (sheet, i) =>
164
+ `<sheet name="${escapeXml(sheet.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`,
165
+ )
166
+ .join('');
167
+ return (
168
+ `${xmlDeclaration()}\n` +
169
+ `<workbook xmlns="${NS_SML}" xmlns:r="${NS_R}">` +
170
+ `<sheets>${sheetEls}</sheets>` +
171
+ `</workbook>`
172
+ );
173
+ }
174
+
175
+ /** Minimal but valid styles part (Excel expects the referenced collections to exist). */
176
+ function stylesXml(): string {
177
+ return (
178
+ `${xmlDeclaration()}\n` +
179
+ `<styleSheet xmlns="${NS_SML}">` +
180
+ `<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>` +
181
+ `<fills count="1"><fill><patternFill patternType="none"/></fill></fills>` +
182
+ `<borders count="1"><border/></borders>` +
183
+ `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>` +
184
+ `<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>` +
185
+ `</styleSheet>`
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Convert a MarkdownDocument to a .xlsx file (tables-only fidelity).
191
+ *
192
+ * Each markdown `table` becomes one worksheet; a document with no tables
193
+ * yields a single empty sheet (a valid, openable file — never throws).
194
+ */
195
+ export async function markdownDocToXlsx(
196
+ doc: MarkdownDocument,
197
+ options: XlsxExportOptions = {},
198
+ ): Promise<ArrayBuffer> {
199
+ const prefix = options.sheetNamePrefix ?? 'Sheet';
200
+ let sheets = collectSheets(doc, prefix);
201
+
202
+ // Zero tables → one empty sheet so the file is still valid.
203
+ if (sheets.length === 0) {
204
+ sheets = [{ name: `${prefix}1`, grid: [] }];
205
+ }
206
+
207
+ const pkg = createPackage();
208
+
209
+ // Worksheets + workbook→worksheet relationships.
210
+ sheets.forEach((sheet, i) => {
211
+ const sheetPath = `xl/worksheets/sheet${i + 1}.xml`;
212
+ pkg.addPart(sheetPath, worksheetXml(sheet.grid), CONTENT_TYPE_XLSX_WORKSHEET);
213
+ pkg.addRelationship('xl/workbook.xml', {
214
+ id: `rId${i + 1}`,
215
+ type: REL_WORKSHEET,
216
+ target: `worksheets/sheet${i + 1}.xml`,
217
+ });
218
+ });
219
+
220
+ // Styles part (referenced after the sheet relationships).
221
+ pkg.addPart('xl/styles.xml', stylesXml(), CONTENT_TYPE_XLSX_STYLES);
222
+ pkg.addRelationship('xl/workbook.xml', {
223
+ id: `rId${sheets.length + 1}`,
224
+ type: REL_STYLES,
225
+ target: 'styles.xml',
226
+ });
227
+
228
+ // Workbook + root relationship.
229
+ pkg.addPart('xl/workbook.xml', workbookXml(sheets), CONTENT_TYPE_XLSX_WORKBOOK);
230
+ pkg.addRelationship('', {
231
+ id: 'rId1',
232
+ type: REL_OFFICE_DOCUMENT,
233
+ target: 'xl/workbook.xml',
234
+ });
235
+
236
+ if (options.title || options.author) {
237
+ pkg.setCoreProperties({
238
+ title: options.title,
239
+ creator: options.author,
240
+ created: new Date().toISOString(),
241
+ modified: new Date().toISOString(),
242
+ });
243
+ }
244
+
245
+ return pkg.toArrayBuffer();
246
+ }
247
+
248
+ /**
249
+ * Convert a squisq Doc to a .xlsx file (via the markdown table model).
250
+ */
251
+ export async function docToXlsx(doc: Doc, options?: XlsxExportOptions): Promise<ArrayBuffer> {
252
+ return markdownDocToXlsx(docToMarkdown(doc), options);
253
+ }
package/src/xlsx/index.ts CHANGED
@@ -1,54 +1,28 @@
1
1
  /**
2
- * @bendyline/squisq-formats XLSX Module (Stub)
2
+ * @bendyline/squisq-formats XLSX Module
3
3
  *
4
- * Placeholder for Excel .xlsx import/export support.
5
- * Will use SpreadsheetML (`<spreadsheet>`, `<worksheet>`, `<sheetData>`)
6
- * via the shared ooxml/ infrastructure.
4
+ * Excel .xlsx support via SpreadsheetML and the shared ooxml/ infrastructure.
5
+ * Both directions are implemented: import (`xlsxToMarkdownDoc` / `xlsxToDoc`)
6
+ * turns each worksheet grid into a markdown table; export (`markdownDocToXlsx`
7
+ * / `docToXlsx`) turns each markdown table into a worksheet. Export is
8
+ * tables-only fidelity — non-table content is dropped (headings only survive
9
+ * as sheet names). See export.ts for details.
7
10
  *
8
11
  * @example
9
12
  * ```ts
10
- * import { markdownDocToXlsx } from '@bendyline/squisq-formats/xlsx';
13
+ * import { xlsxToMarkdownDoc, markdownDocToXlsx } from '@bendyline/squisq-formats/xlsx';
11
14
  * ```
12
15
  */
13
16
 
14
17
  import { markdownToDoc } from '@bendyline/squisq/doc';
15
- import type { MarkdownDocument } from '@bendyline/squisq/markdown';
16
18
  import type { Doc } from '@bendyline/squisq/schemas';
17
19
  import { type XlsxImportOptions, xlsxToMarkdownDoc } from './import.js';
18
20
 
19
21
  export type { XlsxImportOptions } from './import.js';
20
22
  export { xlsxToMarkdownDoc } from './import.js';
21
23
 
22
- /**
23
- * Options for XLSX export (placeholder).
24
- */
25
- export interface XlsxExportOptions {
26
- /** Workbook title */
27
- title?: string;
28
- /** Workbook author */
29
- author?: string;
30
- }
31
-
32
- /**
33
- * Convert a MarkdownDocument to a .xlsx Blob.
34
- *
35
- * @throws Error — XLSX export is not yet implemented
36
- */
37
- export async function markdownDocToXlsx(
38
- _doc: MarkdownDocument,
39
- _options?: XlsxExportOptions,
40
- ): Promise<Blob> {
41
- throw new Error('XLSX export is not yet implemented');
42
- }
43
-
44
- /**
45
- * Convert a squisq Doc to a .xlsx Blob.
46
- *
47
- * @throws Error — XLSX export is not yet implemented
48
- */
49
- export async function docToXlsx(_doc: Doc, _options?: XlsxExportOptions): Promise<Blob> {
50
- throw new Error('XLSX export is not yet implemented');
51
- }
24
+ export type { XlsxExportOptions } from './export.js';
25
+ export { markdownDocToXlsx, docToXlsx } from './export.js';
52
26
 
53
27
  /**
54
28
  * Convert a .xlsx file to a squisq Doc (via the markdown table model).
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/html/imageUtils.ts"],"sourcesContent":["/**\n * Image Utilities for HTML Export\n *\n * Browser-compatible helpers for converting image data to base64 data URIs\n * and inferring MIME types from filenames.\n */\n\n/** Map of file extensions to MIME types */\nconst MIME_MAP: Record<string, string> = {\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n png: 'image/png',\n gif: 'image/gif',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n ico: 'image/x-icon',\n bmp: 'image/bmp',\n avif: 'image/avif',\n mp3: 'audio/mpeg',\n wav: 'audio/wav',\n ogg: 'audio/ogg',\n mp4: 'video/mp4',\n webm: 'video/webm',\n};\n\n/**\n * Infer a MIME type from a filename's extension.\n * Returns 'application/octet-stream' for unknown types.\n */\nexport function inferMimeType(filename: string): string {\n const ext = filename.split('.').pop()?.toLowerCase() ?? '';\n return MIME_MAP[ext] ?? 'application/octet-stream';\n}\n\n/**\n * Convert an ArrayBuffer to a base64-encoded data URI string.\n *\n * @param buffer - The binary image data\n * @param mimeType - MIME type (e.g., 'image/jpeg'). If not provided, defaults to\n * 'application/octet-stream'.\n * @returns A `data:` URI string\n */\nexport function arrayBufferToBase64DataUrl(buffer: ArrayBuffer, mimeType: string): string {\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n const base64 = btoa(binary);\n return `data:${mimeType};base64,${base64}`;\n}\n\n/**\n * Extract the filename from a path or URL (strips directory and query).\n *\n * @example\n * extractFilename('images/hero.jpg') // 'hero.jpg'\n * extractFilename('https://example.com/photo.png?v=2') // 'photo.png'\n */\nexport function extractFilename(path: string): string {\n // Strip query/hash\n const clean = path.split('?')[0].split('#')[0];\n // Get last segment\n const parts = clean.split('/');\n return parts[parts.length - 1] || path;\n}\n"],"mappings":";AAQA,IAAM,WAAmC;AAAA,EACvC,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AACR;AAMO,SAAS,cAAc,UAA0B;AACtD,QAAM,MAAM,SAAS,MAAM,GAAG,EAAE,IAAI,GAAG,YAAY,KAAK;AACxD,SAAO,SAAS,GAAG,KAAK;AAC1B;AAUO,SAAS,2BAA2B,QAAqB,UAA0B;AACxF,QAAM,QAAQ,IAAI,WAAW,MAAM;AACnC,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,cAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAAA,EACxC;AACA,QAAM,SAAS,KAAK,MAAM;AAC1B,SAAO,QAAQ,QAAQ,WAAW,MAAM;AAC1C;AASO,SAAS,gBAAgB,MAAsB;AAEpD,QAAM,QAAQ,KAAK,MAAM,GAAG,EAAE,CAAC,EAAE,MAAM,GAAG,EAAE,CAAC;AAE7C,QAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,SAAO,MAAM,MAAM,SAAS,CAAC,KAAK;AACpC;","names":[]}