@bendyline/squisq-formats 1.3.1 → 1.4.1

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 (107) hide show
  1. package/README.md +180 -43
  2. package/dist/{chunk-33YRFXZZ.js → chunk-46FMDOWZ.js} +260 -14
  3. package/dist/chunk-46FMDOWZ.js.map +1 -0
  4. package/dist/{chunk-ERZ627GR.js → chunk-5LSSWZPU.js} +12 -27
  5. package/dist/chunk-5LSSWZPU.js.map +1 -0
  6. package/dist/chunk-6M7Z25LA.js +46 -0
  7. package/dist/chunk-6M7Z25LA.js.map +1 -0
  8. package/dist/{chunk-S3Y7H2BK.js → chunk-D7IFPWNZ.js} +95 -50
  9. package/dist/chunk-D7IFPWNZ.js.map +1 -0
  10. package/dist/chunk-DQAZR57U.js +33 -0
  11. package/dist/chunk-DQAZR57U.js.map +1 -0
  12. package/dist/chunk-EHLGMSTE.js +273 -0
  13. package/dist/chunk-EHLGMSTE.js.map +1 -0
  14. package/dist/{chunk-A3FHLTY5.js → chunk-MJGRI6XR.js} +6 -19
  15. package/dist/chunk-MJGRI6XR.js.map +1 -0
  16. package/dist/chunk-PN52A5AA.js +32 -0
  17. package/dist/chunk-PN52A5AA.js.map +1 -0
  18. package/dist/chunk-RFAPOKHJ.js +123 -0
  19. package/dist/chunk-RFAPOKHJ.js.map +1 -0
  20. package/dist/chunk-RTS5XBZ7.js +526 -0
  21. package/dist/chunk-RTS5XBZ7.js.map +1 -0
  22. package/dist/{chunk-7DEUGIOO.js → chunk-SHLPSADL.js} +120 -2
  23. package/dist/chunk-SHLPSADL.js.map +1 -0
  24. package/dist/chunk-SSUPBUF5.js +15 -0
  25. package/dist/chunk-SSUPBUF5.js.map +1 -0
  26. package/dist/{chunk-UDS45KUJ.js → chunk-WWBIKRNP.js} +40 -80
  27. package/dist/chunk-WWBIKRNP.js.map +1 -0
  28. package/dist/{chunk-VN2KEOYB.js → chunk-YESS7VY3.js} +379 -87
  29. package/dist/chunk-YESS7VY3.js.map +1 -0
  30. package/dist/csv/index.d.ts +50 -0
  31. package/dist/csv/index.js +13 -0
  32. package/dist/csv/index.js.map +1 -0
  33. package/dist/docx/index.js +6 -3
  34. package/dist/epub/index.js +4 -2
  35. package/dist/html/index.d.ts +42 -2
  36. package/dist/html/index.js +9 -2
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +47 -14
  39. package/dist/ooxml/index.d.ts +5 -1
  40. package/dist/ooxml/index.js +13 -9
  41. package/dist/pdf/index.js +2 -1
  42. package/dist/pptx/index.d.ts +36 -19
  43. package/dist/pptx/index.js +9 -2
  44. package/dist/registry/index.d.ts +167 -0
  45. package/dist/registry/index.js +17 -0
  46. package/dist/registry/index.js.map +1 -0
  47. package/dist/xlsx/index.d.ts +60 -29
  48. package/dist/xlsx/index.js +4 -1
  49. package/package.json +12 -2
  50. package/src/__tests__/convert.test.ts +186 -0
  51. package/src/__tests__/csvImport.test.ts +84 -0
  52. package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
  53. package/src/__tests__/formatRegistry.test.ts +174 -0
  54. package/src/__tests__/htmlImport.test.ts +57 -0
  55. package/src/__tests__/lossyWarnings.test.ts +105 -0
  56. package/src/__tests__/pdfImport.test.ts +95 -1
  57. package/src/__tests__/plainHtml.test.ts +31 -0
  58. package/src/__tests__/pptxExport.test.ts +138 -0
  59. package/src/__tests__/pptxImport.test.ts +115 -0
  60. package/src/__tests__/roundTripAssets.test.ts +50 -0
  61. package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
  62. package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
  63. package/src/__tests__/roundTripMatrix.test.ts +142 -0
  64. package/src/__tests__/sharedContainer.test.ts +41 -0
  65. package/src/__tests__/sharedImages.test.ts +61 -0
  66. package/src/__tests__/xlsxExport.test.ts +164 -0
  67. package/src/__tests__/xlsxImport.test.ts +80 -0
  68. package/src/csv/index.ts +188 -0
  69. package/src/docx/export.ts +27 -44
  70. package/src/docx/import.ts +7 -39
  71. package/src/epub/export.ts +10 -27
  72. package/src/html/imageUtils.ts +5 -19
  73. package/src/html/import.ts +297 -0
  74. package/src/html/index.ts +4 -0
  75. package/src/html/plainHtml.ts +66 -16
  76. package/src/index.ts +36 -5
  77. package/src/ooxml/index.ts +3 -0
  78. package/src/ooxml/namespaces.ts +13 -0
  79. package/src/pdf/export.ts +10 -5
  80. package/src/pdf/import.ts +146 -51
  81. package/src/pptx/export.ts +225 -85
  82. package/src/pptx/import.ts +273 -0
  83. package/src/pptx/index.ts +10 -28
  84. package/src/registry/convert.ts +318 -0
  85. package/src/registry/defaultFormats.ts +323 -0
  86. package/src/registry/errors.ts +46 -0
  87. package/src/registry/index.ts +38 -0
  88. package/src/registry/registry.ts +48 -0
  89. package/src/registry/types.ts +107 -0
  90. package/src/shared/container.ts +28 -0
  91. package/src/shared/images.ts +44 -0
  92. package/src/shared/inlineRuns.ts +99 -0
  93. package/src/shared/text.ts +41 -0
  94. package/src/xlsx/export.ts +253 -0
  95. package/src/xlsx/import.ts +159 -0
  96. package/src/xlsx/index.ts +18 -61
  97. package/dist/chunk-33YRFXZZ.js.map +0 -1
  98. package/dist/chunk-67KIJHV2.js +0 -21
  99. package/dist/chunk-67KIJHV2.js.map +0 -1
  100. package/dist/chunk-7DEUGIOO.js.map +0 -1
  101. package/dist/chunk-A3FHLTY5.js.map +0 -1
  102. package/dist/chunk-ERZ627GR.js.map +0 -1
  103. package/dist/chunk-S3Y7H2BK.js.map +0 -1
  104. package/dist/chunk-UDS45KUJ.js.map +0 -1
  105. package/dist/chunk-VN2KEOYB.js.map +0 -1
  106. package/dist/chunk-YN5HFCEW.js +0 -120
  107. package/dist/chunk-YN5HFCEW.js.map +0 -1
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Structured error for the format registry / `convert()` pipeline.
3
+ *
4
+ * Every failure path in the registry throws a `ConversionError` with a stable
5
+ * machine-readable `code`, so callers can branch on the failure kind rather
6
+ * than string-matching messages.
7
+ */
8
+
9
+ import type { FormatId } from './types.js';
10
+
11
+ /** Machine-readable failure kinds surfaced by the registry / `convert()`. */
12
+ export type ConversionErrorCode =
13
+ | 'unknown-format'
14
+ | 'unsupported-input'
15
+ | 'unsupported-output'
16
+ | 'invalid-input'
17
+ | 'missing-dependency'
18
+ | 'conversion-failed';
19
+
20
+ /** Options for constructing a {@link ConversionError}. */
21
+ export interface ConversionErrorOptions {
22
+ /** The format id the error relates to, if any. */
23
+ format?: FormatId;
24
+ /** A human-oriented remediation hint. */
25
+ hint?: string;
26
+ /** The underlying error, preserved on `error.cause`. */
27
+ cause?: unknown;
28
+ }
29
+
30
+ /** An error raised by the format registry or `convert()`. */
31
+ export class ConversionError extends Error {
32
+ readonly code: ConversionErrorCode;
33
+ readonly format?: FormatId;
34
+ readonly hint?: string;
35
+
36
+ constructor(code: ConversionErrorCode, message: string, opts: ConversionErrorOptions = {}) {
37
+ super(message);
38
+ this.name = 'ConversionError';
39
+ this.code = code;
40
+ this.format = opts.format;
41
+ this.hint = opts.hint;
42
+ if (opts.cause !== undefined) {
43
+ (this as Error & { cause?: unknown }).cause = opts.cause;
44
+ }
45
+ }
46
+ }
@@ -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,99 @@
1
+ /**
2
+ * Shared inline walker for the "run-based" exporters.
3
+ *
4
+ * DOCX (`<w:r>`) and PPTX (`<a:r>`) both render inline markdown by threading an
5
+ * active character-format object DOWN through nested nodes (strong → bold,
6
+ * emphasis → italic, …) and emitting flat styled runs at the leaves — as
7
+ * opposed to the HTML exporters, which WRAP children in tags, or the PDF
8
+ * exporter, which accumulates positioned spans. That control flow is identical
9
+ * between DOCX and PPTX; only the leaf serialization differs.
10
+ *
11
+ * This module owns the traversal once. Each exporter supplies the handful of
12
+ * leaf handlers (`run`, `link`, `image`, `lineBreak`, optional `footnoteRef`)
13
+ * via {@link InlineRunHandlers}. Because the handler interface is typed, adding
14
+ * a new inline node type here forces both exporters to be considered — there's
15
+ * no longer a per-exporter `default:` that can silently drift.
16
+ */
17
+
18
+ import type {
19
+ MarkdownInlineNode,
20
+ MarkdownLink,
21
+ MarkdownImage,
22
+ MarkdownFootnoteReference,
23
+ } from '@bendyline/squisq/markdown';
24
+ import { stripHtmlTags } from './text.js';
25
+
26
+ /** Active character formatting threaded down through nested inline nodes. */
27
+ export interface InlineRunFormat {
28
+ bold?: boolean;
29
+ italic?: boolean;
30
+ strike?: boolean;
31
+ code?: boolean;
32
+ /** Explicit run color (hex without `#`); only some exporters use this. */
33
+ color?: string;
34
+ }
35
+
36
+ /**
37
+ * Per-format leaf handlers. The walker handles the shared format-threading
38
+ * control flow; these turn the leaves into the target dialect's markup.
39
+ */
40
+ export interface InlineRunHandlers {
41
+ /** Emit a styled text run for `text`, given the active format. */
42
+ run(text: string, format: InlineRunFormat): string;
43
+ /** Emit a hyperlink (the handler manages its own child rendering). */
44
+ link(node: MarkdownLink, format: InlineRunFormat): string;
45
+ /** Emit an inline image. */
46
+ image(node: MarkdownImage, format: InlineRunFormat): string;
47
+ /** Emit a hard line break. */
48
+ lineBreak(): string;
49
+ /** Emit a footnote reference. Omit to drop footnote refs (PPTX). */
50
+ footnoteRef?(node: MarkdownFootnoteReference): string;
51
+ }
52
+
53
+ /** Walk a list of inline nodes, concatenating each rendered run. */
54
+ export function inlineNodesToRuns(
55
+ nodes: MarkdownInlineNode[],
56
+ handlers: InlineRunHandlers,
57
+ format: InlineRunFormat = {},
58
+ ): string {
59
+ let out = '';
60
+ for (const node of nodes) {
61
+ out += inlineNodeToRuns(node, handlers, format);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Render a single inline node under the active format. */
67
+ export function inlineNodeToRuns(
68
+ node: MarkdownInlineNode,
69
+ handlers: InlineRunHandlers,
70
+ format: InlineRunFormat,
71
+ ): string {
72
+ switch (node.type) {
73
+ case 'text':
74
+ return handlers.run(node.value, format);
75
+ case 'strong':
76
+ return inlineNodesToRuns(node.children, handlers, { ...format, bold: true });
77
+ case 'emphasis':
78
+ return inlineNodesToRuns(node.children, handlers, { ...format, italic: true });
79
+ case 'delete':
80
+ return inlineNodesToRuns(node.children, handlers, { ...format, strike: true });
81
+ case 'inlineCode':
82
+ return handlers.run(node.value, { ...format, code: true });
83
+ case 'inlineMath':
84
+ return handlers.run(node.value, { ...format, code: true });
85
+ case 'htmlInline':
86
+ return handlers.run(stripHtmlTags(node.rawHtml), format);
87
+ case 'link':
88
+ return handlers.link(node, format);
89
+ case 'image':
90
+ return handlers.image(node, format);
91
+ case 'break':
92
+ return handlers.lineBreak();
93
+ case 'footnoteReference':
94
+ return handlers.footnoteRef ? handlers.footnoteRef(node) : '';
95
+ default:
96
+ // linkReference, imageReference, textDirective, inlineIcon, … — skip.
97
+ return '';
98
+ }
99
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared plain-text helpers used by every format exporter.
3
+ *
4
+ * These were previously copy-pasted into the docx / pptx / epub exporters with
5
+ * subtle drift (e.g. some handled `inlineMath`, some handled `break`). Keeping a
6
+ * single implementation here means a fix or a newly-supported inline node type
7
+ * only has to be added in one place.
8
+ */
9
+
10
+ import type { MarkdownInlineNode } from '@bendyline/squisq/markdown';
11
+
12
+ /** Strip HTML tags from a string, keeping only the text content. */
13
+ export function stripHtmlTags(html: string): string {
14
+ return html.replace(/<[^>]*>/g, '');
15
+ }
16
+
17
+ /** Flatten a single inline node to its plain-text content. */
18
+ export function inlineToPlainText(node: MarkdownInlineNode): string {
19
+ switch (node.type) {
20
+ case 'text':
21
+ case 'inlineCode':
22
+ case 'inlineMath':
23
+ return node.value;
24
+ case 'emphasis':
25
+ case 'strong':
26
+ case 'delete':
27
+ case 'link':
28
+ return extractPlainText(node.children);
29
+ case 'image':
30
+ return node.alt ?? '';
31
+ case 'break':
32
+ return ' ';
33
+ default:
34
+ return '';
35
+ }
36
+ }
37
+
38
+ /** Flatten an array of inline nodes to their concatenated plain-text content. */
39
+ export function extractPlainText(nodes: MarkdownInlineNode[]): string {
40
+ return nodes.map(inlineToPlainText).join('');
41
+ }