@nowline/export 0.8.6 → 0.9.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/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 → layout → renderSvg → format adapter).
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
- // Plan: s2 implements the kernel pipeline (parse → resolve → layout →
14
- // renderSvg → format dispatch). s4 swaps export-png to resvg-wasm.
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
- resolveIncludes,
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 { layoutRoadmap, type ThemeName } from '@nowline/layout';
25
- import { type AssetResolver, type FontFamilies, renderSvg } from '@nowline/renderer';
26
- import type { AstNode, LangiumDocument } from 'langium';
27
- import { URI } from 'langium';
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 fixed canvas width in px. */
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
- /** PDF page size — a preset name or a custom dimensions string. */
96
- pageSize?: string;
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
- function freshUri(): URI {
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
- // Equivalent to packages/cli/src/convert/schema.ts. Inlined here so the kernel
134
- // has no dependency on @nowline/cli.
135
-
136
- const JSON_SCHEMA_VERSION = '1';
137
- const CONTAINER_KEYS = new Set(['$container', '$containerProperty', '$containerIndex']);
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 ast = document.parseResult.value;
191
- return JSON.stringify(
192
- {
193
- $nowlineSchema: JSON_SCHEMA_VERSION,
194
- file: { uri: `file://${sourcePath}`, source },
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
- // ---- Staged pipeline ---------------------------------------------------------
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
- interface StageResult {
216
- /** SVG rendered with the portable FONT_STACK (for `svg` / `html` exports). */
217
- svg: string;
218
- /**
219
- * Re-render the already-laid-out model with a pinned `font-family` map.
220
- * Used by the raster paths (png/pdf) so the exported pixels name exactly
221
- * the bundled font and match the live preview (WYSIWYG). Reuses the same
222
- * asset resolver and strict/warn handling as the portable render.
223
- */
224
- renderWith: (fontFamilies: FontFamilies) => Promise<string>;
225
- exportInputs: ExportInputs;
226
- document: LangiumDocument<NowlineFile>;
227
- source: string;
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
- * Pin the renderer's per-role families to the resolved fonts' real family
232
- * names so raster/preview SVG names exactly the bundled face. `serif` maps to
233
- * the sans family: the resolver has no serif role and raster already falls
234
- * back serif->sans (resvg has no serif face loaded), so this is behavior-
235
- * preserving while making the SVG explicit.
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
- async function stageDocument(
242
- source: string,
243
- inputs: RenderInputs,
244
- host: HostEnv,
245
- ): Promise<StageResult> {
246
- const services = getExportServices();
247
- const uri = freshUri();
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
- const doc = services.shared.workspace.LangiumDocumentFactory.fromString<NowlineFile>(
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 collectDocumentDiagnostics(doc)) {
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
- if (errors.length > 0) {
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}: ${errors.join('; ')}`,
322
+ `@nowline/export: validation errors in ${inputs.sourcePath}: ${errorMessages(stage.diagnostics).join('; ')}`,
268
323
  );
269
324
  }
270
325
 
271
- const ast = doc.parseResult.value;
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
- const resolved = await resolveIncludes(ast, inputs.sourcePath, {
274
- services: services.Nowline,
275
- readFile: host.readSource.bind(host),
276
- });
277
- for (const diag of resolved.diagnostics) {
278
- if (diag.severity === 'error') {
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
- const model = layoutRoadmap(ast, resolved, {
286
- theme: inputs.theme,
287
- today: inputs.today,
288
- width: inputs.width,
289
- locale: inputs.locale,
290
- });
346
+ return stage;
347
+ }
291
348
 
292
- const assetResolver: AssetResolver = async (ref: string) => {
293
- const bytes = await host.readAsset(ref);
294
- return { bytes, mime: guessMime(ref) };
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
- const renderWith = async (fontFamilies?: FontFamilies): Promise<string> => {
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
- // Portable render (FONT_STACK) for the svg/html file exports.
313
- const svg = await renderWith();
314
-
315
- return {
316
- svg,
317
- renderWith: (fontFamilies: FontFamilies) => renderWith(fontFamilies),
318
- exportInputs: {
319
- model,
320
- ast,
321
- resolved,
322
- sourcePath: inputs.sourcePath,
323
- today: inputs.today,
324
- },
325
- document: doc,
326
- source,
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
- if (
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(stage.svg);
467
+ return toUtf8(await portableSvg());
382
468
  }
383
469
 
384
470
  if (format === 'json') {
385
- return toUtf8(serializeDocumentToJson(stage.document, stage.source, inputs.sourcePath));
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(stage.exportInputs, stage.svg);
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(stage.exportInputs);
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(stage.exportInputs, {
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(stage.exportInputs, { generated: inputs.today });
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 rasterSvg = await stage.renderWith(pinnedFamilies(inputs.fonts));
424
- const bytes = await pngMod.exportPng(stage.exportInputs, rasterSvg, {
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 rasterSvg = await stage.renderWith(pinnedFamilies(inputs.fonts));
439
- const bytes = await mod.exportPdf(stage.exportInputs, rasterSvg, {
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,