@nowline/export 0.8.6 → 0.10.0
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/dist/index.d.ts +86 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +201 -159
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/index.ts +294 -201
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @nowline/export — the shared export kernel.
|
|
2
2
|
//
|
|
3
3
|
// The single code path from `.nowline` source to any of the eight export
|
|
4
|
-
// artifacts (parse → resolve includes →
|
|
4
|
+
// artifacts (stageRoadmap: parse → resolve includes → load logo → schedule →
|
|
5
|
+
// layout; then renderSvg → format adapter).
|
|
5
6
|
// Every surface — the CLI, the `@nowline/mcp` server, the VS Code extension,
|
|
6
7
|
// and the Free/Pro web apps — runs this same code with the same inputs, so
|
|
7
8
|
// they all produce byte-for-byte identical output for the same source and the
|
|
@@ -10,24 +11,33 @@
|
|
|
10
11
|
// Spec: specs/export-determinism.md (the byte-identity precedent and the
|
|
11
12
|
// HostEnv / RenderInputs contract this surface implements).
|
|
12
13
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// Staging is `@nowline/renderer`'s `stageRoadmap` (specs/surface-options.md
|
|
15
|
+
// §3.3); `exportStaged` exports a roadmap a caller already staged.
|
|
15
16
|
|
|
16
17
|
import {
|
|
17
|
-
collectDocumentDiagnostics,
|
|
18
18
|
createNowlineServices,
|
|
19
19
|
type NowlineFile,
|
|
20
20
|
type NowlineServices,
|
|
21
|
-
|
|
21
|
+
type RawDiagnostic,
|
|
22
|
+
type ResolveDiagnostic,
|
|
23
|
+
serializeToJson,
|
|
22
24
|
} from '@nowline/core';
|
|
23
25
|
import type { ExportInputs, ResolvedFontPair } from '@nowline/export-core';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
import type { NonWorkingDisplay, ThemeName } from '@nowline/layout';
|
|
27
|
+
import {
|
|
28
|
+
type AssetResolver,
|
|
29
|
+
type FontFamilies,
|
|
30
|
+
renderSvg,
|
|
31
|
+
type StagedRoadmap,
|
|
32
|
+
type StageInputs,
|
|
33
|
+
type StageLayout,
|
|
34
|
+
type StageResult,
|
|
35
|
+
stageRoadmap,
|
|
36
|
+
} from '@nowline/renderer';
|
|
37
|
+
import type { LangiumDocument } from 'langium';
|
|
28
38
|
|
|
29
39
|
export type { PdfOrientation, ResolvedFontPair } from '@nowline/export-core';
|
|
30
|
-
export type { ThemeName } from '@nowline/layout';
|
|
40
|
+
export type { NonWorkingDisplay, ThemeName } from '@nowline/layout';
|
|
31
41
|
export {
|
|
32
42
|
civilDateInZone,
|
|
33
43
|
type NormalizedZone,
|
|
@@ -36,6 +46,26 @@ export {
|
|
|
36
46
|
resolveToday,
|
|
37
47
|
TimezoneError,
|
|
38
48
|
} from '@nowline/layout';
|
|
49
|
+
export type { StagedRoadmap, StageResult } from '@nowline/renderer';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Thrown when include resolution reports a wave-rule error (a coded or
|
|
53
|
+
* `rule: 'wave'` diagnostic, specs/waves.md §6.1) and no other include error.
|
|
54
|
+
* `message` is the same `@nowline/export: include error in …` text as the
|
|
55
|
+
* plain include error; `diagnostics` carries every resolver diagnostic, errors
|
|
56
|
+
* and warnings, so a surface can report the routed ones (see
|
|
57
|
+
* `isRoutedResolveDiagnostic`) with file and line, like validator diagnostics.
|
|
58
|
+
* Every other include error stays a plain `Error`.
|
|
59
|
+
*/
|
|
60
|
+
export class IncludeResolveError extends Error {
|
|
61
|
+
readonly diagnostics: ResolveDiagnostic[];
|
|
62
|
+
|
|
63
|
+
constructor(message: string, diagnostics: ResolveDiagnostic[]) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = 'IncludeResolveError';
|
|
66
|
+
this.diagnostics = diagnostics;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
39
69
|
|
|
40
70
|
/** The eight canonical export formats. Every surface produces exactly these. */
|
|
41
71
|
export type ExportFormat = 'svg' | 'png' | 'pdf' | 'html' | 'mermaid' | 'xlsx' | 'msproj' | 'json';
|
|
@@ -84,16 +114,28 @@ export interface RenderInputs {
|
|
|
84
114
|
fonts?: ResolvedFontPair;
|
|
85
115
|
|
|
86
116
|
// ---- layout / render knobs ------------------------------------------
|
|
87
|
-
/** Optional
|
|
117
|
+
/** Optional best-effort maximum canvas width in px; unset fits the content. */
|
|
88
118
|
width?: number;
|
|
119
|
+
/**
|
|
120
|
+
* How the chart shows a calendar's non-working days (`hide` collapses them,
|
|
121
|
+
* `show` draws shaded bands). Wins over the file's
|
|
122
|
+
* `default roadmap non-working:` key; leave it unset (undefined) to let the
|
|
123
|
+
* key apply, then `hide`. The schedule exporters (mermaid, msproj, xlsx)
|
|
124
|
+
* take no display input, so their bytes are the same in both views.
|
|
125
|
+
*/
|
|
126
|
+
nonWorking?: NonWorkingDisplay;
|
|
89
127
|
/** Drop `<a>` links from the rendered SVG. */
|
|
90
128
|
noLinks?: boolean;
|
|
91
129
|
/** Treat render warnings (e.g. missing assets) as hard errors. */
|
|
92
130
|
strict?: boolean;
|
|
93
131
|
|
|
94
132
|
// ---- format-specific options ----------------------------------------
|
|
95
|
-
/**
|
|
96
|
-
|
|
133
|
+
/**
|
|
134
|
+
* PDF page size: a preset name, a custom dimensions string, or a value
|
|
135
|
+
* already parsed with `parsePageSize` (so a caller can reject a bad one
|
|
136
|
+
* before rendering).
|
|
137
|
+
*/
|
|
138
|
+
pageSize?: import('@nowline/export-core').PdfPageSize | string;
|
|
97
139
|
/** PDF orientation. */
|
|
98
140
|
orientation?: import('@nowline/export-core').PdfOrientation;
|
|
99
141
|
/** PDF margin in points. */
|
|
@@ -102,6 +144,27 @@ export interface RenderInputs {
|
|
|
102
144
|
pngScale?: number;
|
|
103
145
|
/** MS Project start-date override (YYYY-MM-DD). */
|
|
104
146
|
msprojStart?: string;
|
|
147
|
+
|
|
148
|
+
// ---- host callbacks ---------------------------------------------------
|
|
149
|
+
/**
|
|
150
|
+
* Called before layout with the routed (wave-rule) resolver diagnostics
|
|
151
|
+
* when include resolution succeeds, which leaves only warnings (possibly
|
|
152
|
+
* none). Errors throw `IncludeResolveError` instead. Never affects output.
|
|
153
|
+
*/
|
|
154
|
+
onResolveDiagnostics?: (diagnostics: ResolveDiagnostic[]) => void;
|
|
155
|
+
/**
|
|
156
|
+
* Receives non-fatal notices about the artifact, such as the MS Project
|
|
157
|
+
* lossy-feature summary. Never affects output. Omit to keep each
|
|
158
|
+
* exporter's default (stderr), as before.
|
|
159
|
+
*/
|
|
160
|
+
onNotice?: (message: string) => void;
|
|
161
|
+
/**
|
|
162
|
+
* Receives asset warnings, such as a `logo:` file that is missing or
|
|
163
|
+
* unreadable (the render then proceeds without the logo) or SVG logo
|
|
164
|
+
* content the sanitizer removed. Under `strict` they throw instead and
|
|
165
|
+
* this is not called.
|
|
166
|
+
*/
|
|
167
|
+
onWarning?: (message: string) => void;
|
|
105
168
|
}
|
|
106
169
|
|
|
107
170
|
// ---- Langium services singleton -----------------------------------------------
|
|
@@ -115,88 +178,30 @@ type Services = {
|
|
|
115
178
|
};
|
|
116
179
|
|
|
117
180
|
let cachedServices: Services | undefined;
|
|
118
|
-
let docCounter = 0;
|
|
119
181
|
|
|
120
182
|
function getExportServices(): Services {
|
|
121
183
|
if (!cachedServices) cachedServices = createNowlineServices();
|
|
122
184
|
return cachedServices;
|
|
123
185
|
}
|
|
124
186
|
|
|
125
|
-
|
|
126
|
-
// Always mint a fresh URI — re-using one lets Langium mutate the prior
|
|
127
|
-
// document in place, causing spurious diagnostics on repeated exports.
|
|
128
|
-
return URI.parse(`memory:///kernel-${++docCounter}.nowline`);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---- Inline JSON serializer ---------------------------------------------------
|
|
187
|
+
// ---- JSON serializer ---------------------------------------------------------
|
|
132
188
|
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const RUNTIME_KEYS = new Set(['$cstNode', '$document']);
|
|
139
|
-
|
|
140
|
-
function isAstNode(v: unknown): v is AstNode {
|
|
141
|
-
return (
|
|
142
|
-
v !== null && typeof v === 'object' && typeof (v as { $type?: unknown }).$type === 'string'
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function serializeNode(node: AstNode, includePositions: boolean): Record<string, unknown> {
|
|
147
|
-
const out: Record<string, unknown> = { $type: node.$type };
|
|
148
|
-
if (includePositions && node.$cstNode) {
|
|
149
|
-
const cst = node.$cstNode;
|
|
150
|
-
out.$position = {
|
|
151
|
-
start: {
|
|
152
|
-
line: cst.range.start.line + 1,
|
|
153
|
-
column: cst.range.start.character + 1,
|
|
154
|
-
offset: cst.offset,
|
|
155
|
-
},
|
|
156
|
-
end: {
|
|
157
|
-
line: cst.range.end.line + 1,
|
|
158
|
-
column: cst.range.end.character + 1,
|
|
159
|
-
offset: cst.end,
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
for (const [key, value] of Object.entries(node)) {
|
|
164
|
-
if (key.startsWith('$') || CONTAINER_KEYS.has(key) || RUNTIME_KEYS.has(key)) continue;
|
|
165
|
-
out[key] = serializeValue(value, includePositions);
|
|
166
|
-
}
|
|
167
|
-
return out;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function serializeValue(value: unknown, includePositions: boolean): unknown {
|
|
171
|
-
if (value === null || value === undefined) return value;
|
|
172
|
-
if (Array.isArray(value)) return value.map((v) => serializeValue(v, includePositions));
|
|
173
|
-
if (isAstNode(value)) return serializeNode(value, includePositions);
|
|
174
|
-
if (typeof value === 'object') {
|
|
175
|
-
const out: Record<string, unknown> = {};
|
|
176
|
-
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
177
|
-
if (k.startsWith('$') || CONTAINER_KEYS.has(k) || RUNTIME_KEYS.has(k)) continue;
|
|
178
|
-
out[k] = serializeValue(v, includePositions);
|
|
179
|
-
}
|
|
180
|
-
return out;
|
|
181
|
-
}
|
|
182
|
-
return value;
|
|
183
|
-
}
|
|
189
|
+
// Delegates to core's `serializeToJson`. The one envelope difference: the
|
|
190
|
+
// stage parses from a synthetic `memory:///stage-N.nowline` URI, so it
|
|
191
|
+
// overrides `file.uri` with the raw `file://${sourcePath}` concatenation —
|
|
192
|
+
// not `URI.file()`, which would percent-encode and change the bytes the
|
|
193
|
+
// determinism gate hashes.
|
|
184
194
|
|
|
185
195
|
function serializeDocumentToJson(
|
|
186
196
|
document: LangiumDocument<NowlineFile>,
|
|
187
197
|
source: string,
|
|
188
198
|
sourcePath: string,
|
|
189
199
|
): string {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
ast: serializeNode(ast, true),
|
|
196
|
-
},
|
|
197
|
-
null,
|
|
198
|
-
2,
|
|
199
|
-
);
|
|
200
|
+
const doc = serializeToJson(document, source, {
|
|
201
|
+
includePositions: true,
|
|
202
|
+
uri: `file://${sourcePath}`,
|
|
203
|
+
});
|
|
204
|
+
return JSON.stringify(doc, null, 2);
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
// ---- MIME inference ----------------------------------------------------------
|
|
@@ -210,50 +215,59 @@ function guessMime(ref: string): string {
|
|
|
210
215
|
return 'application/octet-stream';
|
|
211
216
|
}
|
|
212
217
|
|
|
213
|
-
|
|
218
|
+
function hostAssetResolver(host: HostEnv): AssetResolver {
|
|
219
|
+
return async (ref: string) => {
|
|
220
|
+
const bytes = await host.readAsset(ref);
|
|
221
|
+
return { bytes, mime: guessMime(ref) };
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- Argument checks ---------------------------------------------------------
|
|
214
226
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
function checkHostAndInputs(host: HostEnv, inputs: RenderInputs): void {
|
|
228
|
+
if (
|
|
229
|
+
typeof host?.readSource !== 'function' ||
|
|
230
|
+
typeof host.readAsset !== 'function' ||
|
|
231
|
+
typeof host.loadWasm !== 'function'
|
|
232
|
+
) {
|
|
233
|
+
throw new TypeError('@nowline/export: host must implement the HostEnv interface.');
|
|
234
|
+
}
|
|
235
|
+
if (!(inputs?.today instanceof Date) && inputs?.today !== undefined) {
|
|
236
|
+
throw new TypeError(
|
|
237
|
+
'@nowline/export: inputs.today must be a Date (UTC midnight) or undefined.',
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (typeof inputs?.sourcePath !== 'string' || inputs.sourcePath.length === 0) {
|
|
241
|
+
throw new TypeError('@nowline/export: inputs.sourcePath must be a non-empty string.');
|
|
242
|
+
}
|
|
228
243
|
}
|
|
229
244
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
function pinnedFamilies(fonts: ResolvedFontPair): FontFamilies {
|
|
238
|
-
return { sans: fonts.sans.name, serif: fonts.sans.name, mono: fonts.mono.name };
|
|
245
|
+
const STAGE_LAYOUT_KEYS = ['theme', 'today', 'width', 'locale', 'nonWorking'] as const;
|
|
246
|
+
|
|
247
|
+
function sameLayoutValue(a: StageLayout[keyof StageLayout], b: unknown): boolean {
|
|
248
|
+
if (a instanceof Date || b instanceof Date) {
|
|
249
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
250
|
+
}
|
|
251
|
+
return a === b;
|
|
239
252
|
}
|
|
240
253
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
254
|
+
/** A stage laid out with other options would export a different chart than `inputs` asks for. */
|
|
255
|
+
function checkStageMatches(stage: StagedRoadmap, inputs: RenderInputs): void {
|
|
256
|
+
for (const key of STAGE_LAYOUT_KEYS) {
|
|
257
|
+
if (!sameLayoutValue(stage.layout[key], inputs[key])) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`@nowline/export: the staged roadmap was laid out with a different ${key} than inputs.${key}.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
248
264
|
|
|
249
|
-
|
|
250
|
-
source,
|
|
251
|
-
uri,
|
|
252
|
-
);
|
|
253
|
-
await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
|
|
265
|
+
// ---- Staging -----------------------------------------------------------------
|
|
254
266
|
|
|
267
|
+
/** Lexer, parser and validator error messages, in document order. */
|
|
268
|
+
function errorMessages(diagnostics: RawDiagnostic[]): string[] {
|
|
255
269
|
const errors: string[] = [];
|
|
256
|
-
for (const raw of
|
|
270
|
+
for (const raw of diagnostics) {
|
|
257
271
|
if (raw.origin === 'lexer') {
|
|
258
272
|
errors.push(raw.error.message);
|
|
259
273
|
} else if (raw.origin === 'parser') {
|
|
@@ -262,69 +276,124 @@ async function stageDocument(
|
|
|
262
276
|
errors.push(raw.diagnostic.message);
|
|
263
277
|
}
|
|
264
278
|
}
|
|
265
|
-
|
|
279
|
+
return errors;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** What a caller that has already parsed `source` may hand to {@link stageForExport}. */
|
|
283
|
+
export interface StageForExportOptions {
|
|
284
|
+
/** A document the caller already parsed and validated from `source`. */
|
|
285
|
+
document?: StageInputs['document'];
|
|
286
|
+
/** The caller's Langium services, which built `document`. Omitted: the kernel's own. */
|
|
287
|
+
services?: StageInputs['services'];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Stage `source` the way the kernel does (`stageRoadmap` with the host's
|
|
292
|
+
* source and asset reads, laid out with `inputs`) and return the raw result:
|
|
293
|
+
* `invalid`, `include-error` or `staged`. A surface that reports the stage's
|
|
294
|
+
* own diagnostics maps them itself, then hands a `staged` result to
|
|
295
|
+
* {@link exportStaged}; {@link acceptStagedRoadmap} is the kernel's mapping.
|
|
296
|
+
*/
|
|
297
|
+
export function stageForExport(
|
|
298
|
+
source: string,
|
|
299
|
+
inputs: RenderInputs,
|
|
300
|
+
host: HostEnv,
|
|
301
|
+
options: StageForExportOptions = {},
|
|
302
|
+
): Promise<StageResult> {
|
|
303
|
+
return stageRoadmap({
|
|
304
|
+
source,
|
|
305
|
+
sourcePath: inputs.sourcePath,
|
|
306
|
+
services: options.services ?? getExportServices(),
|
|
307
|
+
readSource: host.readSource.bind(host),
|
|
308
|
+
assetResolver: hostAssetResolver(host),
|
|
309
|
+
document: options.document,
|
|
310
|
+
layout: inputs,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Map a stage result to the kernel's contract: validation and include errors
|
|
316
|
+
* throw, routed warnings reach `onResolveDiagnostics`, and logo warnings
|
|
317
|
+
* reach `onWarning` (or throw under `strict`). Returns the staged roadmap.
|
|
318
|
+
*/
|
|
319
|
+
export function acceptStagedRoadmap(stage: StageResult, inputs: RenderInputs): StagedRoadmap {
|
|
320
|
+
if (stage.kind === 'invalid') {
|
|
266
321
|
throw new Error(
|
|
267
|
-
`@nowline/export: validation errors in ${inputs.sourcePath}: ${
|
|
322
|
+
`@nowline/export: validation errors in ${inputs.sourcePath}: ${errorMessages(stage.diagnostics).join('; ')}`,
|
|
268
323
|
);
|
|
269
324
|
}
|
|
270
325
|
|
|
271
|
-
|
|
326
|
+
// Include errors are more fundamental than wave-rule errors: any of them
|
|
327
|
+
// keeps the plain throw. Wave-rule errors alone travel on a typed error.
|
|
328
|
+
const includeError = (diag: ResolveDiagnostic): string =>
|
|
329
|
+
`@nowline/export: include error in ${inputs.sourcePath}: ${diag.message}`;
|
|
330
|
+
if (stage.kind === 'include-error') {
|
|
331
|
+
if (stage.legacy) throw new Error(includeError(stage.legacy));
|
|
332
|
+
const firstError = stage.routed.filter((d) => d.severity === 'error')[0];
|
|
333
|
+
throw new IncludeResolveError(includeError(firstError), stage.resolved.diagnostics);
|
|
334
|
+
}
|
|
335
|
+
inputs.onResolveDiagnostics?.(stage.routed);
|
|
272
336
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
throw new Error(
|
|
280
|
-
`@nowline/export: include error in ${inputs.sourcePath}: ${diag.message}`,
|
|
281
|
-
);
|
|
282
|
-
}
|
|
337
|
+
// A failed logo load warns (throws under strict); the stage laid out as
|
|
338
|
+
// if no logo were declared.
|
|
339
|
+
if (inputs.strict && stage.warnings.length > 0) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`@nowline/export: render warnings (--strict): ${stage.warnings.join('; ')}`,
|
|
342
|
+
);
|
|
283
343
|
}
|
|
344
|
+
for (const msg of stage.warnings) inputs.onWarning?.(msg);
|
|
284
345
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
today: inputs.today,
|
|
288
|
-
width: inputs.width,
|
|
289
|
-
locale: inputs.locale,
|
|
290
|
-
});
|
|
346
|
+
return stage;
|
|
347
|
+
}
|
|
291
348
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
349
|
+
/** Stage `source` and map the result to the kernel's contract. */
|
|
350
|
+
async function stageDocument(
|
|
351
|
+
source: string,
|
|
352
|
+
inputs: RenderInputs,
|
|
353
|
+
host: HostEnv,
|
|
354
|
+
): Promise<StagedRoadmap> {
|
|
355
|
+
return acceptStagedRoadmap(await stageForExport(source, inputs, host), inputs);
|
|
356
|
+
}
|
|
296
357
|
|
|
297
|
-
|
|
298
|
-
const warnings: string[] = [];
|
|
299
|
-
const svg = await renderSvg(model, {
|
|
300
|
-
assetResolver,
|
|
301
|
-
noLinks: inputs.noLinks ?? false,
|
|
302
|
-
strict: inputs.strict ?? false,
|
|
303
|
-
warn: (msg) => warnings.push(msg),
|
|
304
|
-
fontFamilies,
|
|
305
|
-
});
|
|
306
|
-
if (inputs.strict && warnings.length > 0) {
|
|
307
|
-
throw new Error(`@nowline/export: render warnings (--strict): ${warnings.join('; ')}`);
|
|
308
|
-
}
|
|
309
|
-
return svg;
|
|
310
|
-
};
|
|
358
|
+
// ---- Rendering ---------------------------------------------------------------
|
|
311
359
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
360
|
+
/**
|
|
361
|
+
* Pin the renderer's per-role families to the resolved fonts' real family
|
|
362
|
+
* names so raster/preview SVG names exactly the bundled face. `serif` maps to
|
|
363
|
+
* the sans family: the resolver has no serif role and raster already falls
|
|
364
|
+
* back serif->sans (resvg has no serif face loaded), so this is behavior-
|
|
365
|
+
* preserving while making the SVG explicit.
|
|
366
|
+
*/
|
|
367
|
+
function pinnedFamilies(fonts: ResolvedFontPair): FontFamilies {
|
|
368
|
+
return { sans: fonts.sans.name, serif: fonts.sans.name, mono: fonts.mono.name };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Render the staged model. The portable render (no `fontFamilies`, the
|
|
373
|
+
* FONT_STACK) reports non-strict warnings to `onWarning`; a font-pinned
|
|
374
|
+
* raster re-render does not repeat them. Every render embeds the stage's
|
|
375
|
+
* logo bytes, so the logo is read once.
|
|
376
|
+
*/
|
|
377
|
+
async function renderStaged(
|
|
378
|
+
stage: StagedRoadmap,
|
|
379
|
+
inputs: RenderInputs,
|
|
380
|
+
assetResolver: AssetResolver,
|
|
381
|
+
fontFamilies?: FontFamilies,
|
|
382
|
+
): Promise<string> {
|
|
383
|
+
const warnings: string[] = [];
|
|
384
|
+
const svg = await renderSvg(stage.model, {
|
|
385
|
+
assetResolver,
|
|
386
|
+
logo: stage.logo,
|
|
387
|
+
noLinks: inputs.noLinks ?? false,
|
|
388
|
+
strict: inputs.strict ?? false,
|
|
389
|
+
warn: (msg) => warnings.push(msg),
|
|
390
|
+
fontFamilies,
|
|
391
|
+
});
|
|
392
|
+
if (inputs.strict && warnings.length > 0) {
|
|
393
|
+
throw new Error(`@nowline/export: render warnings (--strict): ${warnings.join('; ')}`);
|
|
394
|
+
}
|
|
395
|
+
if (!fontFamilies) for (const msg of warnings) inputs.onWarning?.(msg);
|
|
396
|
+
return svg;
|
|
328
397
|
}
|
|
329
398
|
|
|
330
399
|
// ---- Text encoding -----------------------------------------------------------
|
|
@@ -340,15 +409,12 @@ function toUtf8(text: string): Uint8Array {
|
|
|
340
409
|
/**
|
|
341
410
|
* Produce the export artifact for `source` in `format`. This is the single
|
|
342
411
|
* code path to any exported artifact — no surface re-implements a stage.
|
|
412
|
+
* It is `stageRoadmap` plus the kernel's error mapping, then `exportStaged`.
|
|
343
413
|
*
|
|
344
414
|
* Text formats (svg / json / html / mermaid / msproj) are returned as UTF-8
|
|
345
415
|
* bytes; binary formats (png / pdf / xlsx) as their native bytes. Returning
|
|
346
416
|
* Uint8Array for every format keeps the determinism gate able to `sha256` one
|
|
347
417
|
* value per (fixture, format).
|
|
348
|
-
*
|
|
349
|
-
* Note: PNG rasterizes via @nowline/export-png (native resvg-js) until s4
|
|
350
|
-
* swaps it to @resvg/resvg-wasm. The `host.loadWasm()` seam is reserved for
|
|
351
|
-
* that swap and is not called here yet.
|
|
352
418
|
*/
|
|
353
419
|
export async function exportDocument(
|
|
354
420
|
source: string,
|
|
@@ -359,59 +425,85 @@ export async function exportDocument(
|
|
|
359
425
|
if (typeof source !== 'string') {
|
|
360
426
|
throw new TypeError('@nowline/export: source must be a string.');
|
|
361
427
|
}
|
|
362
|
-
|
|
363
|
-
typeof host?.readSource !== 'function' ||
|
|
364
|
-
typeof host.readAsset !== 'function' ||
|
|
365
|
-
typeof host.loadWasm !== 'function'
|
|
366
|
-
) {
|
|
367
|
-
throw new TypeError('@nowline/export: host must implement the HostEnv interface.');
|
|
368
|
-
}
|
|
369
|
-
if (!(inputs?.today instanceof Date) && inputs?.today !== undefined) {
|
|
370
|
-
throw new TypeError(
|
|
371
|
-
'@nowline/export: inputs.today must be a Date (UTC midnight) or undefined.',
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
if (typeof inputs?.sourcePath !== 'string' || inputs.sourcePath.length === 0) {
|
|
375
|
-
throw new TypeError('@nowline/export: inputs.sourcePath must be a non-empty string.');
|
|
376
|
-
}
|
|
377
|
-
|
|
428
|
+
checkHostAndInputs(host, inputs);
|
|
378
429
|
const stage = await stageDocument(source, inputs, host);
|
|
430
|
+
return exportStaged(stage, format, inputs, host);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Export a roadmap the caller already staged with `stageRoadmap`, so a
|
|
435
|
+
* surface that stages once (for diagnostics or layout insights) does not
|
|
436
|
+
* parse and lay out again. The stage must have been laid out with the same
|
|
437
|
+
* theme, today, width, locale and non-working display as `inputs`; a
|
|
438
|
+
* mismatch throws. The caller owns the stage's own errors and warnings
|
|
439
|
+
* (`exportDocument` maps them to the kernel's contract first); this applies
|
|
440
|
+
* `noLinks`, `strict` and `onWarning` to the render only.
|
|
441
|
+
*
|
|
442
|
+
* The SVG is rendered only for the formats that use it (svg, html, png, pdf).
|
|
443
|
+
*/
|
|
444
|
+
export async function exportStaged(
|
|
445
|
+
stage: StagedRoadmap,
|
|
446
|
+
format: ExportFormat,
|
|
447
|
+
inputs: RenderInputs,
|
|
448
|
+
host: HostEnv,
|
|
449
|
+
): Promise<Uint8Array> {
|
|
450
|
+
checkHostAndInputs(host, inputs);
|
|
451
|
+
checkStageMatches(stage, inputs);
|
|
452
|
+
|
|
453
|
+
const assetResolver = hostAssetResolver(host);
|
|
454
|
+
const portableSvg = (): Promise<string> => renderStaged(stage, inputs, assetResolver);
|
|
455
|
+
const rasterSvg = (fonts: ResolvedFontPair): Promise<string> =>
|
|
456
|
+
renderStaged(stage, inputs, assetResolver, pinnedFamilies(fonts));
|
|
457
|
+
const exportInputs: ExportInputs = {
|
|
458
|
+
model: stage.model,
|
|
459
|
+
ast: stage.ast,
|
|
460
|
+
resolved: stage.resolved,
|
|
461
|
+
schedule: stage.schedule,
|
|
462
|
+
sourcePath: inputs.sourcePath,
|
|
463
|
+
today: inputs.today,
|
|
464
|
+
};
|
|
379
465
|
|
|
380
466
|
if (format === 'svg') {
|
|
381
|
-
return toUtf8(
|
|
467
|
+
return toUtf8(await portableSvg());
|
|
382
468
|
}
|
|
383
469
|
|
|
384
470
|
if (format === 'json') {
|
|
385
|
-
|
|
471
|
+
const source = stage.document.textDocument.getText();
|
|
472
|
+
return toUtf8(serializeDocumentToJson(stage.document, source, inputs.sourcePath));
|
|
386
473
|
}
|
|
387
474
|
|
|
388
475
|
if (format === 'html') {
|
|
476
|
+
const svg = await portableSvg();
|
|
389
477
|
const mod = await import('@nowline/export-html');
|
|
390
|
-
const html = await mod.exportHtml(
|
|
478
|
+
const html = await mod.exportHtml(exportInputs, svg);
|
|
391
479
|
return toUtf8(html);
|
|
392
480
|
}
|
|
393
481
|
|
|
394
482
|
if (format === 'mermaid') {
|
|
395
483
|
const mod = await import('@nowline/export-mermaid');
|
|
396
|
-
const md = mod.exportMermaid(
|
|
484
|
+
const md = mod.exportMermaid(exportInputs);
|
|
397
485
|
return toUtf8(md);
|
|
398
486
|
}
|
|
399
487
|
|
|
400
488
|
if (format === 'msproj') {
|
|
401
489
|
const mod = await import('@nowline/export-msproj');
|
|
402
|
-
const xml = mod.exportMsProjXml(
|
|
490
|
+
const xml = mod.exportMsProjXml(exportInputs, {
|
|
403
491
|
startDate: inputs.msprojStart,
|
|
492
|
+
onLossy: inputs.onNotice,
|
|
404
493
|
});
|
|
405
494
|
return toUtf8(xml);
|
|
406
495
|
}
|
|
407
496
|
|
|
408
497
|
if (format === 'xlsx') {
|
|
409
498
|
const mod = await import('@nowline/export-xlsx');
|
|
410
|
-
const bytes = await mod.exportXlsx(
|
|
499
|
+
const bytes = await mod.exportXlsx(exportInputs, { generated: inputs.today });
|
|
411
500
|
return bytes;
|
|
412
501
|
}
|
|
413
502
|
|
|
414
503
|
if (format === 'png') {
|
|
504
|
+
// The portable render still runs first: its warnings reach
|
|
505
|
+
// `onWarning` (or throw under strict) before any raster work.
|
|
506
|
+
await portableSvg();
|
|
415
507
|
if (!inputs.fonts) {
|
|
416
508
|
throw new TypeError('@nowline/export: inputs.fonts is required for png format.');
|
|
417
509
|
}
|
|
@@ -420,8 +512,8 @@ export async function exportDocument(
|
|
|
420
512
|
await pngMod.initPngWasm(wasmBytes);
|
|
421
513
|
// Raster with the pinned bundled family so the SVG resvg rasterizes
|
|
422
514
|
// names exactly the embedded font (matches the live preview).
|
|
423
|
-
const
|
|
424
|
-
const bytes = await pngMod.exportPng(
|
|
515
|
+
const svg = await rasterSvg(inputs.fonts);
|
|
516
|
+
const bytes = await pngMod.exportPng(exportInputs, svg, {
|
|
425
517
|
scale: inputs.pngScale ?? 2,
|
|
426
518
|
fonts: inputs.fonts,
|
|
427
519
|
});
|
|
@@ -429,14 +521,15 @@ export async function exportDocument(
|
|
|
429
521
|
}
|
|
430
522
|
|
|
431
523
|
if (format === 'pdf') {
|
|
524
|
+
await portableSvg();
|
|
432
525
|
if (!inputs.fonts) {
|
|
433
526
|
throw new TypeError('@nowline/export: inputs.fonts is required for pdf format.');
|
|
434
527
|
}
|
|
435
528
|
const mod = await import('@nowline/export-pdf');
|
|
436
529
|
// Same pinned-family raster SVG as PNG so PDF text names the embedded
|
|
437
530
|
// font explicitly and stays consistent with preview + PNG.
|
|
438
|
-
const
|
|
439
|
-
const bytes = await mod.exportPdf(
|
|
531
|
+
const svg = await rasterSvg(inputs.fonts);
|
|
532
|
+
const bytes = await mod.exportPdf(exportInputs, svg, {
|
|
440
533
|
pageSize: inputs.pageSize,
|
|
441
534
|
orientation: inputs.orientation,
|
|
442
535
|
marginPt: inputs.marginPt,
|