@wafflebase/cli 0.3.6 → 0.3.7
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.
- package/README.md +129 -0
- package/dist/bin.js +6 -12
- package/dist/client/http-client.d.ts +4 -1
- package/dist/client/http-client.js +12 -2
- package/dist/commands/api-keys.d.ts +2 -0
- package/dist/commands/{api-key.js → api-keys.js} +5 -2
- package/dist/commands/cells.d.ts +2 -0
- package/dist/commands/{cell.js → cells.js} +5 -2
- package/dist/commands/docs.d.ts +3 -0
- package/dist/commands/docs.js +306 -0
- package/dist/commands/sheets-export.d.ts +2 -0
- package/dist/commands/{export.js → sheets-export.js} +2 -2
- package/dist/commands/sheets-import.d.ts +2 -0
- package/dist/commands/{import.js → sheets-import.js} +2 -2
- package/dist/commands/sheets.d.ts +2 -0
- package/dist/commands/sheets.js +17 -0
- package/dist/commands/tabs.d.ts +2 -0
- package/dist/commands/{tab.js → tabs.js} +2 -2
- package/dist/docs/content.d.ts +37 -0
- package/dist/docs/content.js +90 -0
- package/dist/docs/docx-export.d.ts +23 -0
- package/dist/docs/docx-export.js +14 -0
- package/dist/docs/docx-import.d.ts +36 -0
- package/dist/docs/docx-import.js +70 -0
- package/dist/docs/dom-polyfill.d.ts +1 -0
- package/dist/docs/dom-polyfill.js +29 -0
- package/dist/docs/fontkit-measurer.d.ts +41 -0
- package/dist/docs/fontkit-measurer.js +64 -0
- package/dist/docs/import.d.ts +72 -0
- package/dist/docs/import.js +146 -0
- package/dist/docs/page-range.d.ts +29 -0
- package/dist/docs/page-range.js +74 -0
- package/dist/docs/page-slice.d.ts +45 -0
- package/dist/docs/page-slice.js +61 -0
- package/dist/docs/paginate.d.ts +13 -0
- package/dist/docs/paginate.js +19 -0
- package/dist/docs/pdf-export.d.ts +38 -0
- package/dist/docs/pdf-export.js +91 -0
- package/dist/output/binary.d.ts +37 -0
- package/dist/output/binary.js +47 -0
- package/dist/output/formatter.js +15 -1
- package/dist/schema/registry.d.ts +27 -0
- package/dist/schema/registry.js +103 -23
- package/package.json +7 -2
- package/dist/commands/api-key.d.ts +0 -2
- package/dist/commands/cell.d.ts +0 -2
- package/dist/commands/document.d.ts +0 -2
- package/dist/commands/document.js +0 -93
- package/dist/commands/export.d.ts +0 -2
- package/dist/commands/import.d.ts +0 -2
- package/dist/commands/tab.d.ts +0 -2
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { serializeJson, serializeMarkdown, serializeText, } from '@wafflebase/docs';
|
|
3
|
+
import { FontkitMeasurer } from './fontkit-measurer.js';
|
|
4
|
+
import { parsePageRange } from './page-range.js';
|
|
5
|
+
import { sliceBlocksByPages } from './page-slice.js';
|
|
6
|
+
import { paginateForCli } from './paginate.js';
|
|
7
|
+
export const VALID_CONTENT_FORMATS = ['json', 'md', 'text'];
|
|
8
|
+
export const LOSSY_NOTICE = 'Lossy conversion: see docs-cli design for the exact mapping';
|
|
9
|
+
export const PAGES_FONT_WARNING = 'Note: --pages uses approximate pagination (no document fonts loaded). ' +
|
|
10
|
+
'Page boundaries may drift on CJK or font-heavy documents — ' +
|
|
11
|
+
'use `docs export pdf --pages` for exact subsets.';
|
|
12
|
+
export function parseContentFormat(value) {
|
|
13
|
+
if (!VALID_CONTENT_FORMATS.includes(value)) {
|
|
14
|
+
throw new Error(`Invalid --format "${value}". Use one of: ${VALID_CONTENT_FORMATS.join(', ')}.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export const defaultIO = {
|
|
19
|
+
stdout: (text) => {
|
|
20
|
+
process.stdout.write(text);
|
|
21
|
+
if (!text.endsWith('\n'))
|
|
22
|
+
process.stdout.write('\n');
|
|
23
|
+
},
|
|
24
|
+
stderr: (line) => {
|
|
25
|
+
console.error(line);
|
|
26
|
+
},
|
|
27
|
+
writeFile: (path, text, force) => {
|
|
28
|
+
if (existsSync(path) && !force) {
|
|
29
|
+
throw new Error(`Refusing to overwrite "${path}". Pass --force to allow overwrite.`);
|
|
30
|
+
}
|
|
31
|
+
writeFileSync(path, text.endsWith('\n') ? text : text + '\n', 'utf-8');
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Pure orchestration for `docs content`: takes an already-fetched
|
|
36
|
+
* Document plus user flags, produces the rendered output, and routes it
|
|
37
|
+
* through the supplied IO surface. Everything between the HTTP fetch
|
|
38
|
+
* and the side-effecting IO lives here so it can be tested without
|
|
39
|
+
* spawning the CLI binary.
|
|
40
|
+
*/
|
|
41
|
+
export function runDocsContent(args, io = defaultIO) {
|
|
42
|
+
const { format, pages, includeHeaderFooter = false, inlineImages = false, out, force = false, quiet = false, } = args;
|
|
43
|
+
let working = args.doc;
|
|
44
|
+
let layout;
|
|
45
|
+
if (pages) {
|
|
46
|
+
const measurer = new FontkitMeasurer();
|
|
47
|
+
// `docs content --pages` paginates with a font-less measurer, so
|
|
48
|
+
// every glyph falls back to the 0.5em estimate. That's accurate
|
|
49
|
+
// enough for ASCII-heavy docs but underestimates CJK by ~50% and
|
|
50
|
+
// mis-sizes narrow glyphs (`i`, `:`, `.`) — page boundaries can
|
|
51
|
+
// drift by ±1 page on real documents. `docs export pdf` doesn't
|
|
52
|
+
// suffer from this because PdfPainter does its own measurement at
|
|
53
|
+
// paint time. Pre-loading document fonts here is tracked as a
|
|
54
|
+
// follow-up; until then we surface the limitation on stderr.
|
|
55
|
+
if (!quiet) {
|
|
56
|
+
io.stderr(PAGES_FONT_WARNING);
|
|
57
|
+
}
|
|
58
|
+
const fullLayout = paginateForCli(working, measurer);
|
|
59
|
+
const range = parsePageRange(pages, fullLayout.pages.length);
|
|
60
|
+
if (!quiet) {
|
|
61
|
+
for (const w of range.warnings)
|
|
62
|
+
io.stderr(w);
|
|
63
|
+
}
|
|
64
|
+
const slice = sliceBlocksByPages(working, fullLayout, range, format);
|
|
65
|
+
working = { ...working, blocks: slice.blocks };
|
|
66
|
+
layout = fullLayout;
|
|
67
|
+
}
|
|
68
|
+
if (format === 'md' && !quiet) {
|
|
69
|
+
io.stderr(LOSSY_NOTICE);
|
|
70
|
+
}
|
|
71
|
+
let text;
|
|
72
|
+
switch (format) {
|
|
73
|
+
case 'json':
|
|
74
|
+
text = JSON.stringify(serializeJson(working, layout), null, 2);
|
|
75
|
+
break;
|
|
76
|
+
case 'md':
|
|
77
|
+
text = serializeMarkdown(working, { includeHeaderFooter, inlineImages });
|
|
78
|
+
break;
|
|
79
|
+
case 'text':
|
|
80
|
+
text = serializeText(working, { includeHeaderFooter });
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (!out || out === '-') {
|
|
84
|
+
io.stdout(text);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
io.writeFile(out, text, force);
|
|
88
|
+
if (!quiet)
|
|
89
|
+
io.stderr(`Wrote to ${out}`);
|
|
90
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Document, type DocxImageFetcher } from '@wafflebase/docs';
|
|
2
|
+
export interface CliDocxExportOptions {
|
|
3
|
+
/** Mirrors `--include-header-footer`; informational only — DOCX always
|
|
4
|
+
* emits the document's `header` / `footer` regions when they exist. */
|
|
5
|
+
includeHeaderFooter?: boolean;
|
|
6
|
+
/** Test seam: provide a stub image fetcher so `exportDocx` doesn't
|
|
7
|
+
* reach out to the network for image inlines. The aliased
|
|
8
|
+
* `DocxImageFetcher` (vs. the bare `ImageFetcher`) survives the
|
|
9
|
+
* Node-bundle resolution path; the bare alias only exists on the
|
|
10
|
+
* browser bundle, so importing it would silently break under any TS
|
|
11
|
+
* config that prefers the `node` exports condition. */
|
|
12
|
+
imageFetcher?: DocxImageFetcher;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Render `doc` to a `.docx` byte buffer via the shared `DocxExporter`.
|
|
16
|
+
*
|
|
17
|
+
* `--pages` is intentionally not handled here — the design (§5.2) says
|
|
18
|
+
* the docx path warns and ignores it; the CLI command emits that
|
|
19
|
+
* warning before calling this function so the helper itself stays a
|
|
20
|
+
* pure renderer. Throws if the document contains image inlines and no
|
|
21
|
+
* `imageFetcher` is supplied (matching `DocxExporter.export` behavior).
|
|
22
|
+
*/
|
|
23
|
+
export declare function exportDocx(doc: Document, opts?: CliDocxExportOptions): Promise<Uint8Array>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { DocxExporter, } from '@wafflebase/docs';
|
|
2
|
+
/**
|
|
3
|
+
* Render `doc` to a `.docx` byte buffer via the shared `DocxExporter`.
|
|
4
|
+
*
|
|
5
|
+
* `--pages` is intentionally not handled here — the design (§5.2) says
|
|
6
|
+
* the docx path warns and ignores it; the CLI command emits that
|
|
7
|
+
* warning before calling this function so the helper itself stays a
|
|
8
|
+
* pure renderer. Throws if the document contains image inlines and no
|
|
9
|
+
* `imageFetcher` is supplied (matching `DocxExporter.export` behavior).
|
|
10
|
+
*/
|
|
11
|
+
export async function exportDocx(doc, opts = {}) {
|
|
12
|
+
const blob = await DocxExporter.export(doc, opts.imageFetcher);
|
|
13
|
+
return new Uint8Array(await blob.arrayBuffer());
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import './dom-polyfill.js';
|
|
2
|
+
import { type Document, type ImageUploader } from '@wafflebase/docs';
|
|
3
|
+
/**
|
|
4
|
+
* Default `ImageUploader` for the CLI: encodes each image inline as a
|
|
5
|
+
* `data:` URL. The Yorkie store treats inline-image `src` values as
|
|
6
|
+
* opaque strings, so a self-contained data URL round-trips cleanly
|
|
7
|
+
* without needing a backend image upload endpoint.
|
|
8
|
+
*
|
|
9
|
+
* The MIME type is derived from the filename's extension (matching what
|
|
10
|
+
* `DocxImporter` infers from `.rels` content types). Unknown
|
|
11
|
+
* extensions fall back to `application/octet-stream` — pdf-lib /
|
|
12
|
+
* browser image rendering will refuse to display those, but at least
|
|
13
|
+
* the document round-trip stays lossless at the JSON layer.
|
|
14
|
+
*/
|
|
15
|
+
export declare const inlineBase64Uploader: ImageUploader;
|
|
16
|
+
export interface CliDocxImportOptions {
|
|
17
|
+
/** Override the default base64 uploader — primarily for tests. */
|
|
18
|
+
imageUploader?: ImageUploader;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse a .docx byte buffer into a `Document` using the editor's
|
|
22
|
+
* `DocxImporter`. The polyfill side-effect import at the top of this
|
|
23
|
+
* file installs `DOMParser` from `@xmldom/xmldom` so the importer's
|
|
24
|
+
* browser-targeted XML parsing works in Node.
|
|
25
|
+
*
|
|
26
|
+
* Throws a wrapped `INVALID_DOCX` error when the underlying importer
|
|
27
|
+
* cannot make sense of the buffer (missing `word/document.xml`,
|
|
28
|
+
* malformed XML, unsupported zip layout). The CLI command translates
|
|
29
|
+
* the wrapper into the documented `{ error.code: 'INVALID_DOCX' }`
|
|
30
|
+
* exit body without leaking implementation details.
|
|
31
|
+
*/
|
|
32
|
+
export declare function importDocx(buf: Uint8Array, opts?: CliDocxImportOptions): Promise<Document>;
|
|
33
|
+
export declare class InvalidDocxError extends Error {
|
|
34
|
+
readonly code = "INVALID_DOCX";
|
|
35
|
+
constructor(message: string);
|
|
36
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import './dom-polyfill.js';
|
|
2
|
+
import { DocxImporter, } from '@wafflebase/docs';
|
|
3
|
+
/**
|
|
4
|
+
* Default `ImageUploader` for the CLI: encodes each image inline as a
|
|
5
|
+
* `data:` URL. The Yorkie store treats inline-image `src` values as
|
|
6
|
+
* opaque strings, so a self-contained data URL round-trips cleanly
|
|
7
|
+
* without needing a backend image upload endpoint.
|
|
8
|
+
*
|
|
9
|
+
* The MIME type is derived from the filename's extension (matching what
|
|
10
|
+
* `DocxImporter` infers from `.rels` content types). Unknown
|
|
11
|
+
* extensions fall back to `application/octet-stream` — pdf-lib /
|
|
12
|
+
* browser image rendering will refuse to display those, but at least
|
|
13
|
+
* the document round-trip stays lossless at the JSON layer.
|
|
14
|
+
*/
|
|
15
|
+
export const inlineBase64Uploader = async (blob, filename) => {
|
|
16
|
+
const arrayBuf = await blob.arrayBuffer();
|
|
17
|
+
const base64 = Buffer.from(arrayBuf).toString('base64');
|
|
18
|
+
const mime = blob.type || mimeFromFilename(filename) || 'application/octet-stream';
|
|
19
|
+
return `data:${mime};base64,${base64}`;
|
|
20
|
+
};
|
|
21
|
+
function mimeFromFilename(filename) {
|
|
22
|
+
const dot = filename.lastIndexOf('.');
|
|
23
|
+
if (dot < 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const ext = filename.slice(dot + 1).toLowerCase();
|
|
26
|
+
switch (ext) {
|
|
27
|
+
case 'png':
|
|
28
|
+
return 'image/png';
|
|
29
|
+
case 'jpg':
|
|
30
|
+
case 'jpeg':
|
|
31
|
+
return 'image/jpeg';
|
|
32
|
+
case 'gif':
|
|
33
|
+
return 'image/gif';
|
|
34
|
+
case 'webp':
|
|
35
|
+
return 'image/webp';
|
|
36
|
+
case 'bmp':
|
|
37
|
+
return 'image/bmp';
|
|
38
|
+
default:
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Parse a .docx byte buffer into a `Document` using the editor's
|
|
44
|
+
* `DocxImporter`. The polyfill side-effect import at the top of this
|
|
45
|
+
* file installs `DOMParser` from `@xmldom/xmldom` so the importer's
|
|
46
|
+
* browser-targeted XML parsing works in Node.
|
|
47
|
+
*
|
|
48
|
+
* Throws a wrapped `INVALID_DOCX` error when the underlying importer
|
|
49
|
+
* cannot make sense of the buffer (missing `word/document.xml`,
|
|
50
|
+
* malformed XML, unsupported zip layout). The CLI command translates
|
|
51
|
+
* the wrapper into the documented `{ error.code: 'INVALID_DOCX' }`
|
|
52
|
+
* exit body without leaking implementation details.
|
|
53
|
+
*/
|
|
54
|
+
export async function importDocx(buf, opts = {}) {
|
|
55
|
+
const arrayBuf = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
56
|
+
try {
|
|
57
|
+
return await DocxImporter.import(arrayBuf, opts.imageUploader ?? inlineBase64Uploader);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
61
|
+
throw new InvalidDocxError(message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export class InvalidDocxError extends Error {
|
|
65
|
+
code = 'INVALID_DOCX';
|
|
66
|
+
constructor(message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = 'InvalidDocxError';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { DOMParser as XmldomParser } from '@xmldom/xmldom';
|
|
2
|
+
/**
|
|
3
|
+
* Make `DOMParser` available globally so `@wafflebase/docs`'s
|
|
4
|
+
* `DocxImporter` can call `new DOMParser()` from a Node CLI process.
|
|
5
|
+
*
|
|
6
|
+
* `DocxImporter` was originally written for the browser and parses
|
|
7
|
+
* `.docx` XML through the WHATWG DOMParser. Node has no built-in DOM,
|
|
8
|
+
* so we substitute `@xmldom/xmldom`'s implementation — it's an XML-only
|
|
9
|
+
* parser (no HTML quirks, no full DOM tree mutation) but covers the
|
|
10
|
+
* `getElementsByTagNameNS` / `textContent` / `getAttribute` surface the
|
|
11
|
+
* importer actually touches.
|
|
12
|
+
*
|
|
13
|
+
* The shim is idempotent: if a real `DOMParser` is already present
|
|
14
|
+
* (jsdom-environment tests, future Node releases that ship one) we
|
|
15
|
+
* leave it alone so production code continues to use the more featureful
|
|
16
|
+
* implementation.
|
|
17
|
+
*
|
|
18
|
+
* Side-effect import — bring this file in *before* any code path that
|
|
19
|
+
* calls `DocxImporter.import`.
|
|
20
|
+
*/
|
|
21
|
+
const g = globalThis;
|
|
22
|
+
if (typeof g.DOMParser === 'undefined') {
|
|
23
|
+
// Assign through an `unknown` cast because `@xmldom/xmldom`'s
|
|
24
|
+
// `DOMParser` shape is a runtime superset of WHATWG's but uses its
|
|
25
|
+
// own type declarations. `DocxImporter` only consumes the XML subset
|
|
26
|
+
// both implementations share, so the runtime contract holds even
|
|
27
|
+
// though the static types don't unify.
|
|
28
|
+
g.DOMParser = XmldomParser;
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import type { TextMeasurer, ResolvedFont } from '@wafflebase/docs';
|
|
3
|
+
export interface FontkitMeasurerOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Width per character in em-units used when no font is registered for
|
|
6
|
+
* the requested variant. Multiplied by `font.size` to produce a px value.
|
|
7
|
+
* Defaults to `0.5` — a coarse proportional-font approximation that
|
|
8
|
+
* keeps pagination from collapsing to zero when a font is missing.
|
|
9
|
+
*
|
|
10
|
+
* Callers that pre-load every font referenced by a document never hit
|
|
11
|
+
* this path; the fallback exists so a single missing variant does not
|
|
12
|
+
* crash the CLI mid-pagination.
|
|
13
|
+
*/
|
|
14
|
+
fallbackEmWidth?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `TextMeasurer` backed by `fontkit` for use in Node CLI contexts where
|
|
18
|
+
* no Canvas 2D API is available. Width is computed as
|
|
19
|
+
* `glyphRun.advanceWidth ÷ unitsPerEm × size` for each registered font.
|
|
20
|
+
*
|
|
21
|
+
* Measurement is synchronous; callers must `register()` every font
|
|
22
|
+
* variant they need *before* invoking `paginateLayout` / `measureWidth`.
|
|
23
|
+
* Loading is async (file/network IO) but the layout pipeline is not, so
|
|
24
|
+
* we split the two phases.
|
|
25
|
+
*/
|
|
26
|
+
export declare class FontkitMeasurer implements TextMeasurer {
|
|
27
|
+
private readonly fonts;
|
|
28
|
+
private readonly fallbackEmWidth;
|
|
29
|
+
constructor(opts?: FontkitMeasurerOptions);
|
|
30
|
+
/**
|
|
31
|
+
* Register a font buffer for a specific variant. Subsequent
|
|
32
|
+
* `measureWidth` calls whose `(family, weight, style)` matches will use
|
|
33
|
+
* this font's metrics. A `FontCollection` (.ttc) input is rejected
|
|
34
|
+
* here — the caller must pick the desired face out of the collection
|
|
35
|
+
* first to keep the cache key unambiguous.
|
|
36
|
+
*/
|
|
37
|
+
register(family: string, weight: ResolvedFont['weight'], style: ResolvedFont['style'], buffer: Uint8Array | ArrayBuffer | Buffer): void;
|
|
38
|
+
/** True when the variant has a registered font (no fallback path). */
|
|
39
|
+
has(family: string, weight: ResolvedFont['weight'], style: ResolvedFont['style']): boolean;
|
|
40
|
+
measureWidth(text: string, font: ResolvedFont): number;
|
|
41
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import * as fontkit from 'fontkit';
|
|
3
|
+
/**
|
|
4
|
+
* Variant key for the in-memory font cache. Lowercased family name plus
|
|
5
|
+
* weight/style ensures `'Helvetica'` and `'helvetica'` resolve to the same
|
|
6
|
+
* registered buffer — Canvas measurement is case-insensitive on family
|
|
7
|
+
* lookups, so we mirror that here so the CLI matches browser pagination.
|
|
8
|
+
*/
|
|
9
|
+
function variantKey(family, weight, style) {
|
|
10
|
+
return `${family.toLowerCase()}|${weight}|${style}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* `TextMeasurer` backed by `fontkit` for use in Node CLI contexts where
|
|
14
|
+
* no Canvas 2D API is available. Width is computed as
|
|
15
|
+
* `glyphRun.advanceWidth ÷ unitsPerEm × size` for each registered font.
|
|
16
|
+
*
|
|
17
|
+
* Measurement is synchronous; callers must `register()` every font
|
|
18
|
+
* variant they need *before* invoking `paginateLayout` / `measureWidth`.
|
|
19
|
+
* Loading is async (file/network IO) but the layout pipeline is not, so
|
|
20
|
+
* we split the two phases.
|
|
21
|
+
*/
|
|
22
|
+
export class FontkitMeasurer {
|
|
23
|
+
fonts = new Map();
|
|
24
|
+
fallbackEmWidth;
|
|
25
|
+
constructor(opts = {}) {
|
|
26
|
+
this.fallbackEmWidth = opts.fallbackEmWidth ?? 0.5;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Register a font buffer for a specific variant. Subsequent
|
|
30
|
+
* `measureWidth` calls whose `(family, weight, style)` matches will use
|
|
31
|
+
* this font's metrics. A `FontCollection` (.ttc) input is rejected
|
|
32
|
+
* here — the caller must pick the desired face out of the collection
|
|
33
|
+
* first to keep the cache key unambiguous.
|
|
34
|
+
*/
|
|
35
|
+
register(family, weight, style, buffer) {
|
|
36
|
+
const buf = toBuffer(buffer);
|
|
37
|
+
const parsed = fontkit.create(buf);
|
|
38
|
+
if ('fonts' in parsed) {
|
|
39
|
+
throw new Error(`FontkitMeasurer.register("${family}"): TrueType collections (.ttc) ` +
|
|
40
|
+
`must be split into individual faces before registration.`);
|
|
41
|
+
}
|
|
42
|
+
this.fonts.set(variantKey(family, weight, style), parsed);
|
|
43
|
+
}
|
|
44
|
+
/** True when the variant has a registered font (no fallback path). */
|
|
45
|
+
has(family, weight, style) {
|
|
46
|
+
return this.fonts.has(variantKey(family, weight, style));
|
|
47
|
+
}
|
|
48
|
+
measureWidth(text, font) {
|
|
49
|
+
if (text.length === 0)
|
|
50
|
+
return 0;
|
|
51
|
+
const f = this.fonts.get(variantKey(font.family, font.weight, font.style));
|
|
52
|
+
if (!f)
|
|
53
|
+
return text.length * this.fallbackEmWidth * font.size;
|
|
54
|
+
const run = f.layout(text);
|
|
55
|
+
return (run.advanceWidth / f.unitsPerEm) * font.size;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function toBuffer(input) {
|
|
59
|
+
if (Buffer.isBuffer(input))
|
|
60
|
+
return input;
|
|
61
|
+
if (input instanceof ArrayBuffer)
|
|
62
|
+
return Buffer.from(new Uint8Array(input));
|
|
63
|
+
return Buffer.from(input);
|
|
64
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Document, ImageUploader } from '@wafflebase/docs';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal HTTP surface `runDocsImport` needs from the CLI's
|
|
4
|
+
* `HttpClient`. Spelled out so tests can pass a stub without depending
|
|
5
|
+
* on the full client.
|
|
6
|
+
*/
|
|
7
|
+
export interface ImportClient {
|
|
8
|
+
createDocument: (title: string, type?: 'doc' | 'sheet') => Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
status: number;
|
|
11
|
+
data: unknown;
|
|
12
|
+
}>;
|
|
13
|
+
putDocContent: (docId: string, doc: Document) => Promise<{
|
|
14
|
+
ok: boolean;
|
|
15
|
+
status: number;
|
|
16
|
+
data: unknown;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
export interface ImportIO {
|
|
20
|
+
/** Final structured payload — written to stdout as JSON. */
|
|
21
|
+
stdout: (line: string) => void;
|
|
22
|
+
stderr: (line: string) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Read the docx bytes for `path`. `'-'` reads from stdin.
|
|
25
|
+
*
|
|
26
|
+
* Split out so tests can inject in-memory buffers without juggling
|
|
27
|
+
* tmp files; the default reads `process.stdin` (or the file
|
|
28
|
+
* synchronously) at the action site.
|
|
29
|
+
*/
|
|
30
|
+
readBytes: (path: string) => Promise<Uint8Array>;
|
|
31
|
+
/**
|
|
32
|
+
* Prompt the user for a yes/no answer when running interactively.
|
|
33
|
+
* Returns `true` for affirmative responses (case-insensitive `y` /
|
|
34
|
+
* `yes`); anything else (including end-of-stream) → `false`.
|
|
35
|
+
*/
|
|
36
|
+
confirm: (prompt: string) => Promise<boolean>;
|
|
37
|
+
/** Whether the current process is interactive (a TTY on stdin). * Tests inject `false` to exercise the non-interactive branches. */
|
|
38
|
+
isTTY: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare const defaultImportIO: ImportIO;
|
|
41
|
+
export interface RunImportArgs {
|
|
42
|
+
/** Source path. `'-'` reads from stdin. */
|
|
43
|
+
file: string;
|
|
44
|
+
/** Override the doc title. Falls back to the file basename. */
|
|
45
|
+
title?: string;
|
|
46
|
+
/** Existing document ID to overwrite. Pairs with `--yes`. */
|
|
47
|
+
replace?: string;
|
|
48
|
+
/** Skip the interactive `--replace` confirmation. */
|
|
49
|
+
yes?: boolean;
|
|
50
|
+
/** Override the default base64 image uploader. Tests pass a no-op. */
|
|
51
|
+
imageUploader?: ImageUploader;
|
|
52
|
+
/** Suppress informational stderr output (errors still reported). */
|
|
53
|
+
quiet?: boolean;
|
|
54
|
+
/** Print the request that *would* fire instead of issuing it. */
|
|
55
|
+
dryRun?: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface RunImportResult {
|
|
58
|
+
/** Process exit code. The action sets `process.exitCode` accordingly. */
|
|
59
|
+
exitCode: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pure orchestration for `docs import`. Reads the source `.docx`,
|
|
63
|
+
* parses it into a `Document`, then either creates a new document
|
|
64
|
+
* (default) or overwrites an existing one (`--replace`).
|
|
65
|
+
*
|
|
66
|
+
* Why split out from the command action: the action's layered handling
|
|
67
|
+
* of stdin / TTY / interactive prompt is the easiest path to test
|
|
68
|
+
* regressions in. Mocking `process.stdin` at the harness level is
|
|
69
|
+
* fragile; passing in an `ImportIO` lets us drive every branch
|
|
70
|
+
* deterministically.
|
|
71
|
+
*/
|
|
72
|
+
export declare function runDocsImport(args: RunImportArgs, client: ImportClient, io?: ImportIO): Promise<RunImportResult>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, extname } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { importDocx, InvalidDocxError } from './docx-import.js';
|
|
5
|
+
export const defaultImportIO = {
|
|
6
|
+
stdout: (line) => {
|
|
7
|
+
process.stdout.write(line);
|
|
8
|
+
if (!line.endsWith('\n'))
|
|
9
|
+
process.stdout.write('\n');
|
|
10
|
+
},
|
|
11
|
+
stderr: (line) => {
|
|
12
|
+
console.error(line);
|
|
13
|
+
},
|
|
14
|
+
readBytes: async (path) => {
|
|
15
|
+
if (path === '-') {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const chunks = [];
|
|
18
|
+
process.stdin.on('data', (c) => chunks.push(c));
|
|
19
|
+
process.stdin.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))));
|
|
20
|
+
process.stdin.on('error', reject);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return new Uint8Array(readFileSync(path));
|
|
24
|
+
},
|
|
25
|
+
confirm: (prompt) => {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
28
|
+
rl.question(prompt, (answer) => {
|
|
29
|
+
rl.close();
|
|
30
|
+
const a = answer.trim().toLowerCase();
|
|
31
|
+
resolve(a === 'y' || a === 'yes');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
isTTY: process.stdin.isTTY ?? false,
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Pure orchestration for `docs import`. Reads the source `.docx`,
|
|
39
|
+
* parses it into a `Document`, then either creates a new document
|
|
40
|
+
* (default) or overwrites an existing one (`--replace`).
|
|
41
|
+
*
|
|
42
|
+
* Why split out from the command action: the action's layered handling
|
|
43
|
+
* of stdin / TTY / interactive prompt is the easiest path to test
|
|
44
|
+
* regressions in. Mocking `process.stdin` at the harness level is
|
|
45
|
+
* fragile; passing in an `ImportIO` lets us drive every branch
|
|
46
|
+
* deterministically.
|
|
47
|
+
*/
|
|
48
|
+
export async function runDocsImport(args, client, io = defaultImportIO) {
|
|
49
|
+
const { file, replace, yes = false, imageUploader, quiet = false, dryRun = false } = args;
|
|
50
|
+
const inferredTitle = args.title ?? defaultTitleFor(file);
|
|
51
|
+
if (replace) {
|
|
52
|
+
// `--replace` is destructive: confirm interactively unless `--yes`.
|
|
53
|
+
if (!yes) {
|
|
54
|
+
if (!io.isTTY) {
|
|
55
|
+
io.stderr(JSON.stringify({
|
|
56
|
+
error: {
|
|
57
|
+
code: 'CONFIRMATION_REQ',
|
|
58
|
+
message: `Pass --yes to confirm replacing document "${replace}".`,
|
|
59
|
+
},
|
|
60
|
+
}, null, 2));
|
|
61
|
+
return { exitCode: 1 };
|
|
62
|
+
}
|
|
63
|
+
const ok = await io.confirm(`This will replace content of ${replace}. Continue? [y/N] `);
|
|
64
|
+
if (!ok) {
|
|
65
|
+
if (!quiet)
|
|
66
|
+
io.stderr('Aborted.');
|
|
67
|
+
return { exitCode: 0 };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const buf = await io.readBytes(file);
|
|
71
|
+
const parsed = await safeImportDocx(buf, imageUploader, io);
|
|
72
|
+
if (parsed === null)
|
|
73
|
+
return { exitCode: 1 };
|
|
74
|
+
const doc = parsed;
|
|
75
|
+
if (dryRun) {
|
|
76
|
+
io.stdout(JSON.stringify({ method: 'PUT', path: `/documents/${replace}/content`, body: doc }, null, 2));
|
|
77
|
+
return { exitCode: 0 };
|
|
78
|
+
}
|
|
79
|
+
const res = await client.putDocContent(replace, doc);
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
io.stderr(JSON.stringify(res.data ?? { error: { code: 'HTTP_ERROR' } }, null, 2));
|
|
82
|
+
return { exitCode: 1 };
|
|
83
|
+
}
|
|
84
|
+
io.stdout(JSON.stringify({ id: replace, replaced: true }, null, 2));
|
|
85
|
+
return { exitCode: 0 };
|
|
86
|
+
}
|
|
87
|
+
// Default flow: POST + PUT.
|
|
88
|
+
const buf = await io.readBytes(file);
|
|
89
|
+
const parsedNew = await safeImportDocx(buf, imageUploader, io);
|
|
90
|
+
if (parsedNew === null)
|
|
91
|
+
return { exitCode: 1 };
|
|
92
|
+
const doc = parsedNew;
|
|
93
|
+
if (dryRun) {
|
|
94
|
+
io.stdout(JSON.stringify({
|
|
95
|
+
method: 'POST',
|
|
96
|
+
path: '/documents',
|
|
97
|
+
body: { title: inferredTitle, type: 'doc' },
|
|
98
|
+
followUp: { method: 'PUT', path: '/documents/<new-id>/content' },
|
|
99
|
+
}, null, 2));
|
|
100
|
+
return { exitCode: 0 };
|
|
101
|
+
}
|
|
102
|
+
const created = await client.createDocument(inferredTitle, 'doc');
|
|
103
|
+
if (!created.ok) {
|
|
104
|
+
io.stderr(JSON.stringify(created.data ?? { error: { code: 'HTTP_ERROR' } }, null, 2));
|
|
105
|
+
return { exitCode: 1 };
|
|
106
|
+
}
|
|
107
|
+
const newId = created.data?.id;
|
|
108
|
+
if (!newId) {
|
|
109
|
+
io.stderr(JSON.stringify({ error: { code: 'INVALID_RESPONSE', message: 'Server did not return an id' } }, null, 2));
|
|
110
|
+
return { exitCode: 1 };
|
|
111
|
+
}
|
|
112
|
+
const put = await client.putDocContent(newId, doc);
|
|
113
|
+
if (!put.ok) {
|
|
114
|
+
io.stderr(JSON.stringify(put.data ?? { error: { code: 'HTTP_ERROR' } }, null, 2));
|
|
115
|
+
return { exitCode: 1 };
|
|
116
|
+
}
|
|
117
|
+
io.stdout(JSON.stringify({ id: newId, title: inferredTitle }, null, 2));
|
|
118
|
+
return { exitCode: 0 };
|
|
119
|
+
}
|
|
120
|
+
/** Strip the directory and extension to produce a default doc title. */
|
|
121
|
+
function defaultTitleFor(file) {
|
|
122
|
+
if (file === '-')
|
|
123
|
+
return 'Untitled';
|
|
124
|
+
return basename(file, extname(file)) || 'Untitled';
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Run `importDocx` and turn `InvalidDocxError` into a structured
|
|
128
|
+
* stderr body + `null` return so callers can short-circuit with
|
|
129
|
+
* `exitCode: 1` while keeping the rest of the error envelope
|
|
130
|
+
* (`HTTP_ERROR`, `INVALID_RESPONSE`, etc.) consistent. The action's
|
|
131
|
+
* `outputError` would technically also surface the error code today
|
|
132
|
+
* (post-B1), but catching here keeps the contract local to this file
|
|
133
|
+
* and matches every other failure path in `runDocsImport`.
|
|
134
|
+
*/
|
|
135
|
+
async function safeImportDocx(buf, imageUploader, io) {
|
|
136
|
+
try {
|
|
137
|
+
return await importDocx(buf, { imageUploader });
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
if (e instanceof InvalidDocxError) {
|
|
141
|
+
io.stderr(JSON.stringify({ error: { code: e.code, message: e.message } }, null, 2));
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved page selection produced by {@link parsePageRange}.
|
|
3
|
+
*
|
|
4
|
+
* `pages` is the deduplicated set of 1-based page numbers requested by
|
|
5
|
+
* the user, after clamping to the document's actual page count.
|
|
6
|
+
*
|
|
7
|
+
* `warnings` collects clamp/duplicate notices the caller should emit to
|
|
8
|
+
* stderr (parsing succeeded; the user just asked for pages that don't
|
|
9
|
+
* exist). Hard errors throw instead — see {@link parsePageRange}.
|
|
10
|
+
*/
|
|
11
|
+
export interface PageRange {
|
|
12
|
+
pages: ReadonlySet<number>;
|
|
13
|
+
warnings: ReadonlyArray<string>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse a `--pages` flag value (e.g., `"1-3,5,7-9"`) against a known
|
|
17
|
+
* page count.
|
|
18
|
+
*
|
|
19
|
+
* Throws `Error` on malformed input — empty strings, non-numeric tokens,
|
|
20
|
+
* a `0` page (1-based), or a reversed range (`"3-1"`). Out-of-range
|
|
21
|
+
* upper bounds are *not* errors: they get clamped to `totalPages` with a
|
|
22
|
+
* stderr-bound warning string in the result. Tokens whose entire range
|
|
23
|
+
* sits past `totalPages` collapse to no pages with a warning, never an
|
|
24
|
+
* error — this lets `--pages 1-99` work on a 3-page doc.
|
|
25
|
+
*
|
|
26
|
+
* `totalPages` of 0 is a valid input (empty doc) and yields an empty set
|
|
27
|
+
* with a single "document has no pages" warning, regardless of input.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parsePageRange(input: string, totalPages: number): PageRange;
|