@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/dist/index.d.ts CHANGED
@@ -1,8 +1,24 @@
1
+ import { type ResolveDiagnostic } from '@nowline/core';
1
2
  import type { ResolvedFontPair } from '@nowline/export-core';
2
- import { type ThemeName } from '@nowline/layout';
3
+ import type { NonWorkingDisplay, ThemeName } from '@nowline/layout';
4
+ import { type StagedRoadmap, type StageInputs, type StageResult } from '@nowline/renderer';
3
5
  export type { PdfOrientation, ResolvedFontPair } from '@nowline/export-core';
4
- export type { ThemeName } from '@nowline/layout';
6
+ export type { NonWorkingDisplay, ThemeName } from '@nowline/layout';
5
7
  export { civilDateInZone, type NormalizedZone, normalizeZone, type ResolveTodayOptions, resolveToday, TimezoneError, } from '@nowline/layout';
8
+ export type { StagedRoadmap, StageResult } from '@nowline/renderer';
9
+ /**
10
+ * Thrown when include resolution reports a wave-rule error (a coded or
11
+ * `rule: 'wave'` diagnostic, specs/waves.md §6.1) and no other include error.
12
+ * `message` is the same `@nowline/export: include error in …` text as the
13
+ * plain include error; `diagnostics` carries every resolver diagnostic, errors
14
+ * and warnings, so a surface can report the routed ones (see
15
+ * `isRoutedResolveDiagnostic`) with file and line, like validator diagnostics.
16
+ * Every other include error stays a plain `Error`.
17
+ */
18
+ export declare class IncludeResolveError extends Error {
19
+ readonly diagnostics: ResolveDiagnostic[];
20
+ constructor(message: string, diagnostics: ResolveDiagnostic[]);
21
+ }
6
22
  /** The eight canonical export formats. Every surface produces exactly these. */
7
23
  export type ExportFormat = 'svg' | 'png' | 'pdf' | 'html' | 'mermaid' | 'xlsx' | 'msproj' | 'json';
8
24
  /**
@@ -46,14 +62,26 @@ export interface RenderInputs {
46
62
  * Optional for text-only formats (svg, html, json, mermaid, msproj, xlsx).
47
63
  */
48
64
  fonts?: ResolvedFontPair;
49
- /** Optional fixed canvas width in px. */
65
+ /** Optional best-effort maximum canvas width in px; unset fits the content. */
50
66
  width?: number;
67
+ /**
68
+ * How the chart shows a calendar's non-working days (`hide` collapses them,
69
+ * `show` draws shaded bands). Wins over the file's
70
+ * `default roadmap non-working:` key; leave it unset (undefined) to let the
71
+ * key apply, then `hide`. The schedule exporters (mermaid, msproj, xlsx)
72
+ * take no display input, so their bytes are the same in both views.
73
+ */
74
+ nonWorking?: NonWorkingDisplay;
51
75
  /** Drop `<a>` links from the rendered SVG. */
52
76
  noLinks?: boolean;
53
77
  /** Treat render warnings (e.g. missing assets) as hard errors. */
54
78
  strict?: boolean;
55
- /** PDF page size — a preset name or a custom dimensions string. */
56
- pageSize?: string;
79
+ /**
80
+ * PDF page size: a preset name, a custom dimensions string, or a value
81
+ * already parsed with `parsePageSize` (so a caller can reject a bad one
82
+ * before rendering).
83
+ */
84
+ pageSize?: import('@nowline/export-core').PdfPageSize | string;
57
85
  /** PDF orientation. */
58
86
  orientation?: import('@nowline/export-core').PdfOrientation;
59
87
  /** PDF margin in points. */
@@ -62,19 +90,68 @@ export interface RenderInputs {
62
90
  pngScale?: number;
63
91
  /** MS Project start-date override (YYYY-MM-DD). */
64
92
  msprojStart?: string;
93
+ /**
94
+ * Called before layout with the routed (wave-rule) resolver diagnostics
95
+ * when include resolution succeeds, which leaves only warnings (possibly
96
+ * none). Errors throw `IncludeResolveError` instead. Never affects output.
97
+ */
98
+ onResolveDiagnostics?: (diagnostics: ResolveDiagnostic[]) => void;
99
+ /**
100
+ * Receives non-fatal notices about the artifact, such as the MS Project
101
+ * lossy-feature summary. Never affects output. Omit to keep each
102
+ * exporter's default (stderr), as before.
103
+ */
104
+ onNotice?: (message: string) => void;
105
+ /**
106
+ * Receives asset warnings, such as a `logo:` file that is missing or
107
+ * unreadable (the render then proceeds without the logo) or SVG logo
108
+ * content the sanitizer removed. Under `strict` they throw instead and
109
+ * this is not called.
110
+ */
111
+ onWarning?: (message: string) => void;
112
+ }
113
+ /** What a caller that has already parsed `source` may hand to {@link stageForExport}. */
114
+ export interface StageForExportOptions {
115
+ /** A document the caller already parsed and validated from `source`. */
116
+ document?: StageInputs['document'];
117
+ /** The caller's Langium services, which built `document`. Omitted: the kernel's own. */
118
+ services?: StageInputs['services'];
65
119
  }
120
+ /**
121
+ * Stage `source` the way the kernel does (`stageRoadmap` with the host's
122
+ * source and asset reads, laid out with `inputs`) and return the raw result:
123
+ * `invalid`, `include-error` or `staged`. A surface that reports the stage's
124
+ * own diagnostics maps them itself, then hands a `staged` result to
125
+ * {@link exportStaged}; {@link acceptStagedRoadmap} is the kernel's mapping.
126
+ */
127
+ export declare function stageForExport(source: string, inputs: RenderInputs, host: HostEnv, options?: StageForExportOptions): Promise<StageResult>;
128
+ /**
129
+ * Map a stage result to the kernel's contract: validation and include errors
130
+ * throw, routed warnings reach `onResolveDiagnostics`, and logo warnings
131
+ * reach `onWarning` (or throw under `strict`). Returns the staged roadmap.
132
+ */
133
+ export declare function acceptStagedRoadmap(stage: StageResult, inputs: RenderInputs): StagedRoadmap;
66
134
  /**
67
135
  * Produce the export artifact for `source` in `format`. This is the single
68
136
  * code path to any exported artifact — no surface re-implements a stage.
137
+ * It is `stageRoadmap` plus the kernel's error mapping, then `exportStaged`.
69
138
  *
70
139
  * Text formats (svg / json / html / mermaid / msproj) are returned as UTF-8
71
140
  * bytes; binary formats (png / pdf / xlsx) as their native bytes. Returning
72
141
  * Uint8Array for every format keeps the determinism gate able to `sha256` one
73
142
  * value per (fixture, format).
74
- *
75
- * Note: PNG rasterizes via @nowline/export-png (native resvg-js) until s4
76
- * swaps it to @resvg/resvg-wasm. The `host.loadWasm()` seam is reserved for
77
- * that swap and is not called here yet.
78
143
  */
79
144
  export declare function exportDocument(source: string, format: ExportFormat, inputs: RenderInputs, host: HostEnv): Promise<Uint8Array>;
145
+ /**
146
+ * Export a roadmap the caller already staged with `stageRoadmap`, so a
147
+ * surface that stages once (for diagnostics or layout insights) does not
148
+ * parse and lay out again. The stage must have been laid out with the same
149
+ * theme, today, width, locale and non-working display as `inputs`; a
150
+ * mismatch throws. The caller owns the stage's own errors and warnings
151
+ * (`exportDocument` maps them to the kernel's contract first); this applies
152
+ * `noLinks`, `strict` and `onWarning` to the render only.
153
+ *
154
+ * The SVG is rendered only for the formats that use it (svg, html, png, pdf).
155
+ */
156
+ export declare function exportStaged(stage: StagedRoadmap, format: ExportFormat, inputs: RenderInputs, host: HostEnv): Promise<Uint8Array>;
80
157
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAgB,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAiB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAKhE,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC7E,YAAY,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EACH,eAAe,EACf,KAAK,cAAc,EACnB,aAAa,EACb,KAAK,mBAAmB,EACxB,YAAY,EACZ,aAAa,GAChB,MAAM,iBAAiB,CAAC;AAEzB,gFAAgF;AAChF,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEnG;;;;;;GAMG;AACH,MAAM,WAAW,OAAO;IACpB,uFAAuF;IACvF,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1C,oEAAoE;IACpE,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC7C,gEAAgE;IAChE,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;CACpC;AAED;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IACzB;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,yGAAyG;IACzG,KAAK,CAAC,EAAE,IAAI,CAAC;IACb,kFAAkF;IAClF,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,KAAK,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAGzB,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8CAA8C;IAC9C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kEAAkE;IAClE,MAAM,CAAC,EAAE,OAAO,CAAC;IAGjB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uBAAuB;IACvB,WAAW,CAAC,EAAE,OAAO,sBAAsB,EAAE,cAAc,CAAC;IAC5D,4BAA4B;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gDAAgD;IAChD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AA2OD;;;;;;;;;;;;GAYG;AACH,wBAAsB,cAAc,CAChC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,YAAY,EACpB,IAAI,EAAE,OAAO,GACd,OAAO,CAAC,UAAU,CAAC,CA2FrB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js 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,84 +11,47 @@
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.
15
- import { collectDocumentDiagnostics, createNowlineServices, resolveIncludes, } from '@nowline/core';
16
- import { layoutRoadmap } from '@nowline/layout';
17
- import { renderSvg } from '@nowline/renderer';
18
- import { URI } from 'langium';
14
+ // Staging is `@nowline/renderer`'s `stageRoadmap` (specs/surface-options.md
15
+ // §3.3); `exportStaged` exports a roadmap a caller already staged.
16
+ import { createNowlineServices, serializeToJson, } from '@nowline/core';
17
+ import { renderSvg, stageRoadmap, } from '@nowline/renderer';
19
18
  export { civilDateInZone, normalizeZone, resolveToday, TimezoneError, } from '@nowline/layout';
19
+ /**
20
+ * Thrown when include resolution reports a wave-rule error (a coded or
21
+ * `rule: 'wave'` diagnostic, specs/waves.md §6.1) and no other include error.
22
+ * `message` is the same `@nowline/export: include error in …` text as the
23
+ * plain include error; `diagnostics` carries every resolver diagnostic, errors
24
+ * and warnings, so a surface can report the routed ones (see
25
+ * `isRoutedResolveDiagnostic`) with file and line, like validator diagnostics.
26
+ * Every other include error stays a plain `Error`.
27
+ */
28
+ export class IncludeResolveError extends Error {
29
+ diagnostics;
30
+ constructor(message, diagnostics) {
31
+ super(message);
32
+ this.name = 'IncludeResolveError';
33
+ this.diagnostics = diagnostics;
34
+ }
35
+ }
20
36
  let cachedServices;
21
- let docCounter = 0;
22
37
  function getExportServices() {
23
38
  if (!cachedServices)
24
39
  cachedServices = createNowlineServices();
25
40
  return cachedServices;
26
41
  }
27
- function freshUri() {
28
- // Always mint a fresh URI — re-using one lets Langium mutate the prior
29
- // document in place, causing spurious diagnostics on repeated exports.
30
- return URI.parse(`memory:///kernel-${++docCounter}.nowline`);
31
- }
32
- // ---- Inline JSON serializer ---------------------------------------------------
42
+ // ---- JSON serializer ---------------------------------------------------------
33
43
  //
34
- // Equivalent to packages/cli/src/convert/schema.ts. Inlined here so the kernel
35
- // has no dependency on @nowline/cli.
36
- const JSON_SCHEMA_VERSION = '1';
37
- const CONTAINER_KEYS = new Set(['$container', '$containerProperty', '$containerIndex']);
38
- const RUNTIME_KEYS = new Set(['$cstNode', '$document']);
39
- function isAstNode(v) {
40
- return (v !== null && typeof v === 'object' && typeof v.$type === 'string');
41
- }
42
- function serializeNode(node, includePositions) {
43
- const out = { $type: node.$type };
44
- if (includePositions && node.$cstNode) {
45
- const cst = node.$cstNode;
46
- out.$position = {
47
- start: {
48
- line: cst.range.start.line + 1,
49
- column: cst.range.start.character + 1,
50
- offset: cst.offset,
51
- },
52
- end: {
53
- line: cst.range.end.line + 1,
54
- column: cst.range.end.character + 1,
55
- offset: cst.end,
56
- },
57
- };
58
- }
59
- for (const [key, value] of Object.entries(node)) {
60
- if (key.startsWith('$') || CONTAINER_KEYS.has(key) || RUNTIME_KEYS.has(key))
61
- continue;
62
- out[key] = serializeValue(value, includePositions);
63
- }
64
- return out;
65
- }
66
- function serializeValue(value, includePositions) {
67
- if (value === null || value === undefined)
68
- return value;
69
- if (Array.isArray(value))
70
- return value.map((v) => serializeValue(v, includePositions));
71
- if (isAstNode(value))
72
- return serializeNode(value, includePositions);
73
- if (typeof value === 'object') {
74
- const out = {};
75
- for (const [k, v] of Object.entries(value)) {
76
- if (k.startsWith('$') || CONTAINER_KEYS.has(k) || RUNTIME_KEYS.has(k))
77
- continue;
78
- out[k] = serializeValue(v, includePositions);
79
- }
80
- return out;
81
- }
82
- return value;
83
- }
44
+ // Delegates to core's `serializeToJson`. The one envelope difference: the
45
+ // stage parses from a synthetic `memory:///stage-N.nowline` URI, so it
46
+ // overrides `file.uri` with the raw `file://${sourcePath}` concatenation —
47
+ // not `URI.file()`, which would percent-encode and change the bytes the
48
+ // determinism gate hashes.
84
49
  function serializeDocumentToJson(document, source, sourcePath) {
85
- const ast = document.parseResult.value;
86
- return JSON.stringify({
87
- $nowlineSchema: JSON_SCHEMA_VERSION,
88
- file: { uri: `file://${sourcePath}`, source },
89
- ast: serializeNode(ast, true),
90
- }, null, 2);
50
+ const doc = serializeToJson(document, source, {
51
+ includePositions: true,
52
+ uri: `file://${sourcePath}`,
53
+ });
54
+ return JSON.stringify(doc, null, 2);
91
55
  }
92
56
  // ---- MIME inference ----------------------------------------------------------
93
57
  function guessMime(ref) {
@@ -102,23 +66,46 @@ function guessMime(ref) {
102
66
  return 'image/webp';
103
67
  return 'application/octet-stream';
104
68
  }
105
- /**
106
- * Pin the renderer's per-role families to the resolved fonts' real family
107
- * names so raster/preview SVG names exactly the bundled face. `serif` maps to
108
- * the sans family: the resolver has no serif role and raster already falls
109
- * back serif->sans (resvg has no serif face loaded), so this is behavior-
110
- * preserving while making the SVG explicit.
111
- */
112
- function pinnedFamilies(fonts) {
113
- return { sans: fonts.sans.name, serif: fonts.sans.name, mono: fonts.mono.name };
69
+ function hostAssetResolver(host) {
70
+ return async (ref) => {
71
+ const bytes = await host.readAsset(ref);
72
+ return { bytes, mime: guessMime(ref) };
73
+ };
114
74
  }
115
- async function stageDocument(source, inputs, host) {
116
- const services = getExportServices();
117
- const uri = freshUri();
118
- const doc = services.shared.workspace.LangiumDocumentFactory.fromString(source, uri);
119
- await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
75
+ // ---- Argument checks ---------------------------------------------------------
76
+ function checkHostAndInputs(host, inputs) {
77
+ if (typeof host?.readSource !== 'function' ||
78
+ typeof host.readAsset !== 'function' ||
79
+ typeof host.loadWasm !== 'function') {
80
+ throw new TypeError('@nowline/export: host must implement the HostEnv interface.');
81
+ }
82
+ if (!(inputs?.today instanceof Date) && inputs?.today !== undefined) {
83
+ throw new TypeError('@nowline/export: inputs.today must be a Date (UTC midnight) or undefined.');
84
+ }
85
+ if (typeof inputs?.sourcePath !== 'string' || inputs.sourcePath.length === 0) {
86
+ throw new TypeError('@nowline/export: inputs.sourcePath must be a non-empty string.');
87
+ }
88
+ }
89
+ const STAGE_LAYOUT_KEYS = ['theme', 'today', 'width', 'locale', 'nonWorking'];
90
+ function sameLayoutValue(a, b) {
91
+ if (a instanceof Date || b instanceof Date) {
92
+ return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
93
+ }
94
+ return a === b;
95
+ }
96
+ /** A stage laid out with other options would export a different chart than `inputs` asks for. */
97
+ function checkStageMatches(stage, inputs) {
98
+ for (const key of STAGE_LAYOUT_KEYS) {
99
+ if (!sameLayoutValue(stage.layout[key], inputs[key])) {
100
+ throw new Error(`@nowline/export: the staged roadmap was laid out with a different ${key} than inputs.${key}.`);
101
+ }
102
+ }
103
+ }
104
+ // ---- Staging -----------------------------------------------------------------
105
+ /** Lexer, parser and validator error messages, in document order. */
106
+ function errorMessages(diagnostics) {
120
107
  const errors = [];
121
- for (const raw of collectDocumentDiagnostics(doc)) {
108
+ for (const raw of diagnostics) {
122
109
  if (raw.origin === 'lexer') {
123
110
  errors.push(raw.error.message);
124
111
  }
@@ -129,58 +116,92 @@ async function stageDocument(source, inputs, host) {
129
116
  errors.push(raw.diagnostic.message);
130
117
  }
131
118
  }
132
- if (errors.length > 0) {
133
- throw new Error(`@nowline/export: validation errors in ${inputs.sourcePath}: ${errors.join('; ')}`);
134
- }
135
- const ast = doc.parseResult.value;
136
- const resolved = await resolveIncludes(ast, inputs.sourcePath, {
137
- services: services.Nowline,
138
- readFile: host.readSource.bind(host),
119
+ return errors;
120
+ }
121
+ /**
122
+ * Stage `source` the way the kernel does (`stageRoadmap` with the host's
123
+ * source and asset reads, laid out with `inputs`) and return the raw result:
124
+ * `invalid`, `include-error` or `staged`. A surface that reports the stage's
125
+ * own diagnostics maps them itself, then hands a `staged` result to
126
+ * {@link exportStaged}; {@link acceptStagedRoadmap} is the kernel's mapping.
127
+ */
128
+ export function stageForExport(source, inputs, host, options = {}) {
129
+ return stageRoadmap({
130
+ source,
131
+ sourcePath: inputs.sourcePath,
132
+ services: options.services ?? getExportServices(),
133
+ readSource: host.readSource.bind(host),
134
+ assetResolver: hostAssetResolver(host),
135
+ document: options.document,
136
+ layout: inputs,
139
137
  });
140
- for (const diag of resolved.diagnostics) {
141
- if (diag.severity === 'error') {
142
- throw new Error(`@nowline/export: include error in ${inputs.sourcePath}: ${diag.message}`);
143
- }
138
+ }
139
+ /**
140
+ * Map a stage result to the kernel's contract: validation and include errors
141
+ * throw, routed warnings reach `onResolveDiagnostics`, and logo warnings
142
+ * reach `onWarning` (or throw under `strict`). Returns the staged roadmap.
143
+ */
144
+ export function acceptStagedRoadmap(stage, inputs) {
145
+ if (stage.kind === 'invalid') {
146
+ throw new Error(`@nowline/export: validation errors in ${inputs.sourcePath}: ${errorMessages(stage.diagnostics).join('; ')}`);
144
147
  }
145
- const model = layoutRoadmap(ast, resolved, {
146
- theme: inputs.theme,
147
- today: inputs.today,
148
- width: inputs.width,
149
- locale: inputs.locale,
148
+ // Include errors are more fundamental than wave-rule errors: any of them
149
+ // keeps the plain throw. Wave-rule errors alone travel on a typed error.
150
+ const includeError = (diag) => `@nowline/export: include error in ${inputs.sourcePath}: ${diag.message}`;
151
+ if (stage.kind === 'include-error') {
152
+ if (stage.legacy)
153
+ throw new Error(includeError(stage.legacy));
154
+ const firstError = stage.routed.filter((d) => d.severity === 'error')[0];
155
+ throw new IncludeResolveError(includeError(firstError), stage.resolved.diagnostics);
156
+ }
157
+ inputs.onResolveDiagnostics?.(stage.routed);
158
+ // A failed logo load warns (throws under strict); the stage laid out as
159
+ // if no logo were declared.
160
+ if (inputs.strict && stage.warnings.length > 0) {
161
+ throw new Error(`@nowline/export: render warnings (--strict): ${stage.warnings.join('; ')}`);
162
+ }
163
+ for (const msg of stage.warnings)
164
+ inputs.onWarning?.(msg);
165
+ return stage;
166
+ }
167
+ /** Stage `source` and map the result to the kernel's contract. */
168
+ async function stageDocument(source, inputs, host) {
169
+ return acceptStagedRoadmap(await stageForExport(source, inputs, host), inputs);
170
+ }
171
+ // ---- Rendering ---------------------------------------------------------------
172
+ /**
173
+ * Pin the renderer's per-role families to the resolved fonts' real family
174
+ * names so raster/preview SVG names exactly the bundled face. `serif` maps to
175
+ * the sans family: the resolver has no serif role and raster already falls
176
+ * back serif->sans (resvg has no serif face loaded), so this is behavior-
177
+ * preserving while making the SVG explicit.
178
+ */
179
+ function pinnedFamilies(fonts) {
180
+ return { sans: fonts.sans.name, serif: fonts.sans.name, mono: fonts.mono.name };
181
+ }
182
+ /**
183
+ * Render the staged model. The portable render (no `fontFamilies`, the
184
+ * FONT_STACK) reports non-strict warnings to `onWarning`; a font-pinned
185
+ * raster re-render does not repeat them. Every render embeds the stage's
186
+ * logo bytes, so the logo is read once.
187
+ */
188
+ async function renderStaged(stage, inputs, assetResolver, fontFamilies) {
189
+ const warnings = [];
190
+ const svg = await renderSvg(stage.model, {
191
+ assetResolver,
192
+ logo: stage.logo,
193
+ noLinks: inputs.noLinks ?? false,
194
+ strict: inputs.strict ?? false,
195
+ warn: (msg) => warnings.push(msg),
196
+ fontFamilies,
150
197
  });
151
- const assetResolver = async (ref) => {
152
- const bytes = await host.readAsset(ref);
153
- return { bytes, mime: guessMime(ref) };
154
- };
155
- const renderWith = async (fontFamilies) => {
156
- const warnings = [];
157
- const svg = await renderSvg(model, {
158
- assetResolver,
159
- noLinks: inputs.noLinks ?? false,
160
- strict: inputs.strict ?? false,
161
- warn: (msg) => warnings.push(msg),
162
- fontFamilies,
163
- });
164
- if (inputs.strict && warnings.length > 0) {
165
- throw new Error(`@nowline/export: render warnings (--strict): ${warnings.join('; ')}`);
166
- }
167
- return svg;
168
- };
169
- // Portable render (FONT_STACK) for the svg/html file exports.
170
- const svg = await renderWith();
171
- return {
172
- svg,
173
- renderWith: (fontFamilies) => renderWith(fontFamilies),
174
- exportInputs: {
175
- model,
176
- ast,
177
- resolved,
178
- sourcePath: inputs.sourcePath,
179
- today: inputs.today,
180
- },
181
- document: doc,
182
- source,
183
- };
198
+ if (inputs.strict && warnings.length > 0) {
199
+ throw new Error(`@nowline/export: render warnings (--strict): ${warnings.join('; ')}`);
200
+ }
201
+ if (!fontFamilies)
202
+ for (const msg of warnings)
203
+ inputs.onWarning?.(msg);
204
+ return svg;
184
205
  }
185
206
  // ---- Text encoding -----------------------------------------------------------
186
207
  const encoder = new TextEncoder();
@@ -191,61 +212,81 @@ function toUtf8(text) {
191
212
  /**
192
213
  * Produce the export artifact for `source` in `format`. This is the single
193
214
  * code path to any exported artifact — no surface re-implements a stage.
215
+ * It is `stageRoadmap` plus the kernel's error mapping, then `exportStaged`.
194
216
  *
195
217
  * Text formats (svg / json / html / mermaid / msproj) are returned as UTF-8
196
218
  * bytes; binary formats (png / pdf / xlsx) as their native bytes. Returning
197
219
  * Uint8Array for every format keeps the determinism gate able to `sha256` one
198
220
  * value per (fixture, format).
199
- *
200
- * Note: PNG rasterizes via @nowline/export-png (native resvg-js) until s4
201
- * swaps it to @resvg/resvg-wasm. The `host.loadWasm()` seam is reserved for
202
- * that swap and is not called here yet.
203
221
  */
204
222
  export async function exportDocument(source, format, inputs, host) {
205
223
  if (typeof source !== 'string') {
206
224
  throw new TypeError('@nowline/export: source must be a string.');
207
225
  }
208
- if (typeof host?.readSource !== 'function' ||
209
- typeof host.readAsset !== 'function' ||
210
- typeof host.loadWasm !== 'function') {
211
- throw new TypeError('@nowline/export: host must implement the HostEnv interface.');
212
- }
213
- if (!(inputs?.today instanceof Date) && inputs?.today !== undefined) {
214
- throw new TypeError('@nowline/export: inputs.today must be a Date (UTC midnight) or undefined.');
215
- }
216
- if (typeof inputs?.sourcePath !== 'string' || inputs.sourcePath.length === 0) {
217
- throw new TypeError('@nowline/export: inputs.sourcePath must be a non-empty string.');
218
- }
226
+ checkHostAndInputs(host, inputs);
219
227
  const stage = await stageDocument(source, inputs, host);
228
+ return exportStaged(stage, format, inputs, host);
229
+ }
230
+ /**
231
+ * Export a roadmap the caller already staged with `stageRoadmap`, so a
232
+ * surface that stages once (for diagnostics or layout insights) does not
233
+ * parse and lay out again. The stage must have been laid out with the same
234
+ * theme, today, width, locale and non-working display as `inputs`; a
235
+ * mismatch throws. The caller owns the stage's own errors and warnings
236
+ * (`exportDocument` maps them to the kernel's contract first); this applies
237
+ * `noLinks`, `strict` and `onWarning` to the render only.
238
+ *
239
+ * The SVG is rendered only for the formats that use it (svg, html, png, pdf).
240
+ */
241
+ export async function exportStaged(stage, format, inputs, host) {
242
+ checkHostAndInputs(host, inputs);
243
+ checkStageMatches(stage, inputs);
244
+ const assetResolver = hostAssetResolver(host);
245
+ const portableSvg = () => renderStaged(stage, inputs, assetResolver);
246
+ const rasterSvg = (fonts) => renderStaged(stage, inputs, assetResolver, pinnedFamilies(fonts));
247
+ const exportInputs = {
248
+ model: stage.model,
249
+ ast: stage.ast,
250
+ resolved: stage.resolved,
251
+ schedule: stage.schedule,
252
+ sourcePath: inputs.sourcePath,
253
+ today: inputs.today,
254
+ };
220
255
  if (format === 'svg') {
221
- return toUtf8(stage.svg);
256
+ return toUtf8(await portableSvg());
222
257
  }
223
258
  if (format === 'json') {
224
- return toUtf8(serializeDocumentToJson(stage.document, stage.source, inputs.sourcePath));
259
+ const source = stage.document.textDocument.getText();
260
+ return toUtf8(serializeDocumentToJson(stage.document, source, inputs.sourcePath));
225
261
  }
226
262
  if (format === 'html') {
263
+ const svg = await portableSvg();
227
264
  const mod = await import('@nowline/export-html');
228
- const html = await mod.exportHtml(stage.exportInputs, stage.svg);
265
+ const html = await mod.exportHtml(exportInputs, svg);
229
266
  return toUtf8(html);
230
267
  }
231
268
  if (format === 'mermaid') {
232
269
  const mod = await import('@nowline/export-mermaid');
233
- const md = mod.exportMermaid(stage.exportInputs);
270
+ const md = mod.exportMermaid(exportInputs);
234
271
  return toUtf8(md);
235
272
  }
236
273
  if (format === 'msproj') {
237
274
  const mod = await import('@nowline/export-msproj');
238
- const xml = mod.exportMsProjXml(stage.exportInputs, {
275
+ const xml = mod.exportMsProjXml(exportInputs, {
239
276
  startDate: inputs.msprojStart,
277
+ onLossy: inputs.onNotice,
240
278
  });
241
279
  return toUtf8(xml);
242
280
  }
243
281
  if (format === 'xlsx') {
244
282
  const mod = await import('@nowline/export-xlsx');
245
- const bytes = await mod.exportXlsx(stage.exportInputs, { generated: inputs.today });
283
+ const bytes = await mod.exportXlsx(exportInputs, { generated: inputs.today });
246
284
  return bytes;
247
285
  }
248
286
  if (format === 'png') {
287
+ // The portable render still runs first: its warnings reach
288
+ // `onWarning` (or throw under strict) before any raster work.
289
+ await portableSvg();
249
290
  if (!inputs.fonts) {
250
291
  throw new TypeError('@nowline/export: inputs.fonts is required for png format.');
251
292
  }
@@ -254,22 +295,23 @@ export async function exportDocument(source, format, inputs, host) {
254
295
  await pngMod.initPngWasm(wasmBytes);
255
296
  // Raster with the pinned bundled family so the SVG resvg rasterizes
256
297
  // names exactly the embedded font (matches the live preview).
257
- const rasterSvg = await stage.renderWith(pinnedFamilies(inputs.fonts));
258
- const bytes = await pngMod.exportPng(stage.exportInputs, rasterSvg, {
298
+ const svg = await rasterSvg(inputs.fonts);
299
+ const bytes = await pngMod.exportPng(exportInputs, svg, {
259
300
  scale: inputs.pngScale ?? 2,
260
301
  fonts: inputs.fonts,
261
302
  });
262
303
  return bytes;
263
304
  }
264
305
  if (format === 'pdf') {
306
+ await portableSvg();
265
307
  if (!inputs.fonts) {
266
308
  throw new TypeError('@nowline/export: inputs.fonts is required for pdf format.');
267
309
  }
268
310
  const mod = await import('@nowline/export-pdf');
269
311
  // Same pinned-family raster SVG as PNG so PDF text names the embedded
270
312
  // font explicitly and stays consistent with preview + PNG.
271
- const rasterSvg = await stage.renderWith(pinnedFamilies(inputs.fonts));
272
- const bytes = await mod.exportPdf(stage.exportInputs, rasterSvg, {
313
+ const svg = await rasterSvg(inputs.fonts);
314
+ const bytes = await mod.exportPdf(exportInputs, svg, {
273
315
  pageSize: inputs.pageSize,
274
316
  orientation: inputs.orientation,
275
317
  marginPt: inputs.marginPt,