@nowline/export-core 0.2.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.
Files changed (60) hide show
  1. package/README.md +131 -0
  2. package/assets/fonts/DejaVuSans.ttf +0 -0
  3. package/assets/fonts/DejaVuSansMono.ttf +0 -0
  4. package/assets/fonts/LICENSE-DejaVu.txt +187 -0
  5. package/dist/ast-helpers.d.ts +16 -0
  6. package/dist/ast-helpers.d.ts.map +1 -0
  7. package/dist/ast-helpers.js +44 -0
  8. package/dist/ast-helpers.js.map +1 -0
  9. package/dist/fonts/bundled.d.ts +7 -0
  10. package/dist/fonts/bundled.d.ts.map +1 -0
  11. package/dist/fonts/bundled.js +52 -0
  12. package/dist/fonts/bundled.js.map +1 -0
  13. package/dist/fonts/index.d.ts +6 -0
  14. package/dist/fonts/index.d.ts.map +1 -0
  15. package/dist/fonts/index.js +5 -0
  16. package/dist/fonts/index.js.map +1 -0
  17. package/dist/fonts/probe-list.d.ts +29 -0
  18. package/dist/fonts/probe-list.d.ts.map +1 -0
  19. package/dist/fonts/probe-list.js +119 -0
  20. package/dist/fonts/probe-list.js.map +1 -0
  21. package/dist/fonts/resolve.d.ts +35 -0
  22. package/dist/fonts/resolve.d.ts.map +1 -0
  23. package/dist/fonts/resolve.js +159 -0
  24. package/dist/fonts/resolve.js.map +1 -0
  25. package/dist/fonts/sfns.d.ts +6 -0
  26. package/dist/fonts/sfns.d.ts.map +1 -0
  27. package/dist/fonts/sfns.js +37 -0
  28. package/dist/fonts/sfns.js.map +1 -0
  29. package/dist/generated/bundled-fonts.d.ts +3 -0
  30. package/dist/generated/bundled-fonts.d.ts.map +1 -0
  31. package/dist/generated/bundled-fonts.js +9 -0
  32. package/dist/generated/bundled-fonts.js.map +1 -0
  33. package/dist/index.d.ts +9 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +8 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/pdf-page.d.ts +66 -0
  38. package/dist/pdf-page.d.ts.map +1 -0
  39. package/dist/pdf-page.js +170 -0
  40. package/dist/pdf-page.js.map +1 -0
  41. package/dist/types.d.ts +61 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +6 -0
  44. package/dist/types.js.map +1 -0
  45. package/dist/units.d.ts +16 -0
  46. package/dist/units.d.ts.map +1 -0
  47. package/dist/units.js +56 -0
  48. package/dist/units.js.map +1 -0
  49. package/package.json +50 -0
  50. package/src/ast-helpers.ts +47 -0
  51. package/src/fonts/bundled.ts +59 -0
  52. package/src/fonts/index.ts +18 -0
  53. package/src/fonts/probe-list.ts +143 -0
  54. package/src/fonts/resolve.ts +228 -0
  55. package/src/fonts/sfns.ts +34 -0
  56. package/src/generated/bundled-fonts.ts +10 -0
  57. package/src/index.ts +62 -0
  58. package/src/pdf-page.ts +224 -0
  59. package/src/types.ts +88 -0
  60. package/src/units.ts +60 -0
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ export {
2
+ displayLabel,
3
+ getProp,
4
+ getProps,
5
+ hasProp,
6
+ type PropertyHost,
7
+ roadmapTitle,
8
+ } from './ast-helpers.js';
9
+ export {
10
+ BUNDLED_MONO_PATH,
11
+ BUNDLED_SANS_PATH,
12
+ clearBundledCache,
13
+ loadBundledMono,
14
+ loadBundledSans,
15
+ } from './fonts/bundled.js';
16
+ export {
17
+ ALIASES,
18
+ aliasCandidate,
19
+ type FontCandidate,
20
+ isAlias,
21
+ type PlatformProbe,
22
+ probeListFor,
23
+ } from './fonts/probe-list.js';
24
+
25
+ export {
26
+ FontResolveError,
27
+ type ResolveOptions,
28
+ type ResolveResult,
29
+ resolveFonts,
30
+ } from './fonts/resolve.js';
31
+
32
+ export { isVariableFontBytes } from './fonts/sfns.js';
33
+ export {
34
+ type ContentScale,
35
+ fitContent,
36
+ isPdfPresetName,
37
+ PageSizeParseError,
38
+ parsePageSize,
39
+ presetDimensions,
40
+ presetNames,
41
+ type ResolvedPage,
42
+ resolvePage,
43
+ validateMargin,
44
+ } from './pdf-page.js';
45
+ export type {
46
+ ExportInputs,
47
+ FontRole,
48
+ FontSource,
49
+ PdfLength,
50
+ PdfLengthUnit,
51
+ PdfOrientation,
52
+ PdfPageSize,
53
+ PdfPresetName,
54
+ ResolvedFont,
55
+ ResolvedFontPair,
56
+ } from './types.js';
57
+ export {
58
+ LengthParseError,
59
+ lengthToPoints,
60
+ parseLength,
61
+ pointsToLength,
62
+ } from './units.js';
@@ -0,0 +1,224 @@
1
+ // PDF page-size parsing, preset table, and orientation/margin resolution.
2
+ //
3
+ // Spec: specs/handoffs/m2c.md § 4 "PDF — vector PDF via PDFKit"
4
+ // - Default: US Letter (8.5 × 11 in) portrait.
5
+ // - Presets: imperial (letter, legal, tabloid, ledger) + ISO 216 (a5–a1, b5–b3).
6
+ // - Custom: WxHunit (mixed units rejected).
7
+ // - "content": page = content dimensions, no scaling, no upper bound.
8
+
9
+ import type { PdfLength, PdfOrientation, PdfPageSize, PdfPresetName } from './types.js';
10
+ import { LengthParseError, lengthToPoints, parseLength } from './units.js';
11
+
12
+ interface PresetDimensions {
13
+ widthPt: number;
14
+ heightPt: number;
15
+ }
16
+
17
+ function fromMm(widthMm: number, heightMm: number): PresetDimensions {
18
+ const factor = 72 / 25.4;
19
+ return {
20
+ widthPt: roundToMicropoint(widthMm * factor),
21
+ heightPt: roundToMicropoint(heightMm * factor),
22
+ };
23
+ }
24
+
25
+ function roundToMicropoint(n: number): number {
26
+ return Math.round(n * 1000) / 1000;
27
+ }
28
+
29
+ const PRESETS: Readonly<Record<PdfPresetName, PresetDimensions>> = {
30
+ letter: { widthPt: 612, heightPt: 792 },
31
+ legal: { widthPt: 612, heightPt: 1008 },
32
+ tabloid: { widthPt: 792, heightPt: 1224 },
33
+ ledger: { widthPt: 1224, heightPt: 792 },
34
+ a1: fromMm(594, 841),
35
+ a2: fromMm(420, 594),
36
+ a3: fromMm(297, 420),
37
+ a4: fromMm(210, 297),
38
+ a5: fromMm(148, 210),
39
+ b3: fromMm(353, 500),
40
+ b4: fromMm(250, 353),
41
+ b5: fromMm(176, 250),
42
+ };
43
+
44
+ const PRESET_NAMES = Object.keys(PRESETS) as readonly PdfPresetName[];
45
+
46
+ export class PageSizeParseError extends Error {
47
+ constructor(message: string) {
48
+ super(message);
49
+ this.name = 'PageSizeParseError';
50
+ }
51
+ }
52
+
53
+ export function isPdfPresetName(value: string): value is PdfPresetName {
54
+ return (PRESET_NAMES as readonly string[]).includes(value.toLowerCase());
55
+ }
56
+
57
+ export function presetNames(): readonly PdfPresetName[] {
58
+ return PRESET_NAMES;
59
+ }
60
+
61
+ export function presetDimensions(name: PdfPresetName): PresetDimensions {
62
+ return PRESETS[name];
63
+ }
64
+
65
+ // Canonical form: `<W>x<H><unit>` — single trailing unit applies to both
66
+ // (e.g. `8.5x11in`).
67
+ // Explicit form: `<W><unit>x<H><unit>` — both dimensions tagged. Same unit
68
+ // required; mismatch is rejected as "mixed units".
69
+ const CUSTOM_RE = /^(\d+(?:\.\d+)?)([a-z]+)?x(\d+(?:\.\d+)?)([a-z]+)$/i;
70
+
71
+ /**
72
+ * Parse a `--page-size` value: preset name, custom `WxHunit`, or `content`.
73
+ * Case-insensitive.
74
+ */
75
+ export function parsePageSize(input: string): PdfPageSize {
76
+ const lower = input.trim().toLowerCase();
77
+ if (!lower) throw new PageSizeParseError('empty page size');
78
+ if (lower === 'content') return { kind: 'content' };
79
+ if (isPdfPresetName(lower)) {
80
+ return { kind: 'preset', name: lower as PdfPresetName };
81
+ }
82
+
83
+ const match = CUSTOM_RE.exec(lower);
84
+ if (!match) {
85
+ throw new PageSizeParseError(
86
+ `invalid page size "${input}": expected preset (${PRESET_NAMES.join(', ')}), "content", or <W>x<H><unit> (e.g. 8.5x11in)`,
87
+ );
88
+ }
89
+ const [, wRaw, wUnitMaybe, hRaw, hUnit] = match;
90
+ if (wUnitMaybe !== undefined && wUnitMaybe !== hUnit) {
91
+ throw new PageSizeParseError(
92
+ `invalid page size "${input}": mixed units (${wUnitMaybe} vs ${hUnit}); use the same unit for width and height`,
93
+ );
94
+ }
95
+
96
+ let width: PdfLength;
97
+ let height: PdfLength;
98
+ try {
99
+ width = parseLength(`${wRaw}${hUnit}`);
100
+ height = parseLength(`${hRaw}${hUnit}`);
101
+ } catch (err) {
102
+ if (err instanceof LengthParseError) {
103
+ throw new PageSizeParseError(`invalid page size "${input}": ${err.message}`);
104
+ }
105
+ throw err;
106
+ }
107
+ return { kind: 'custom', width, height };
108
+ }
109
+
110
+ export interface ResolvedPage {
111
+ widthPt: number;
112
+ heightPt: number;
113
+ orientation: 'portrait' | 'landscape';
114
+ isContentSized: boolean;
115
+ }
116
+
117
+ /**
118
+ * Resolve the final page rectangle (in points) from page size, orientation,
119
+ * and content dimensions.
120
+ *
121
+ * Rules per § 4 "Orientation" / "Scaling":
122
+ * - `--page-size content` → page = content + 2 × margin; orientation derived from
123
+ * the resulting aspect; `orientation` argument is ignored.
124
+ * - Fixed page (preset or custom):
125
+ * `auto` → portrait if content taller-than-wide, landscape otherwise.
126
+ * `portrait` / `landscape` → swap the preset W/H if the preset is in the
127
+ * opposite orientation.
128
+ */
129
+ export function resolvePage(args: {
130
+ pageSize: PdfPageSize;
131
+ orientation: PdfOrientation;
132
+ contentWidthPt: number;
133
+ contentHeightPt: number;
134
+ marginPt: number;
135
+ }): ResolvedPage {
136
+ const { pageSize, orientation, contentWidthPt, contentHeightPt, marginPt } = args;
137
+
138
+ if (pageSize.kind === 'content') {
139
+ const widthPt = contentWidthPt + 2 * marginPt;
140
+ const heightPt = contentHeightPt + 2 * marginPt;
141
+ return {
142
+ widthPt,
143
+ heightPt,
144
+ orientation: widthPt >= heightPt ? 'landscape' : 'portrait',
145
+ isContentSized: true,
146
+ };
147
+ }
148
+
149
+ let widthPt: number;
150
+ let heightPt: number;
151
+ if (pageSize.kind === 'preset') {
152
+ ({ widthPt, heightPt } = PRESETS[pageSize.name]);
153
+ } else {
154
+ widthPt = lengthToPoints(pageSize.width);
155
+ heightPt = lengthToPoints(pageSize.height);
156
+ }
157
+
158
+ const resolvedOrientation: 'portrait' | 'landscape' =
159
+ orientation === 'auto'
160
+ ? contentWidthPt > contentHeightPt
161
+ ? 'landscape'
162
+ : 'portrait'
163
+ : orientation;
164
+
165
+ if (resolvedOrientation === 'landscape' && widthPt < heightPt) {
166
+ [widthPt, heightPt] = [heightPt, widthPt];
167
+ } else if (resolvedOrientation === 'portrait' && widthPt > heightPt) {
168
+ [widthPt, heightPt] = [heightPt, widthPt];
169
+ }
170
+
171
+ return { widthPt, heightPt, orientation: resolvedOrientation, isContentSized: false };
172
+ }
173
+
174
+ /**
175
+ * Validate a margin against the resolved page. `margin × 2 ≥ either dim`
176
+ * means the printable area collapses; throw with a pointer at `--margin`.
177
+ */
178
+ export function validateMargin(marginPt: number, page: ResolvedPage): void {
179
+ if (!Number.isFinite(marginPt) || marginPt < 0) {
180
+ throw new PageSizeParseError('invalid margin: must be a non-negative number of points');
181
+ }
182
+ if (marginPt * 2 >= page.widthPt || marginPt * 2 >= page.heightPt) {
183
+ throw new PageSizeParseError(
184
+ `margin ${marginPt}pt consumes the entire ${page.widthPt}x${page.heightPt}pt page`,
185
+ );
186
+ }
187
+ }
188
+
189
+ export interface ContentScale {
190
+ /** Factor applied to content; 1 = native, < 1 = shrink to fit. */
191
+ factor: number;
192
+ /** Top-left of where the (scaled) content begins inside the page. */
193
+ offsetX: number;
194
+ offsetY: number;
195
+ }
196
+
197
+ /**
198
+ * For fixed-page mode, fit (centered, never up-scaled) the content rectangle
199
+ * inside the printable area `(page − 2 × margin)`. For content-sized pages,
200
+ * the factor is always 1 and the offset is just the margin.
201
+ */
202
+ export function fitContent(args: {
203
+ page: ResolvedPage;
204
+ contentWidthPt: number;
205
+ contentHeightPt: number;
206
+ marginPt: number;
207
+ }): ContentScale {
208
+ const { page, contentWidthPt, contentHeightPt, marginPt } = args;
209
+ if (page.isContentSized) {
210
+ return { factor: 1, offsetX: marginPt, offsetY: marginPt };
211
+ }
212
+ const printableW = page.widthPt - 2 * marginPt;
213
+ const printableH = page.heightPt - 2 * marginPt;
214
+ const scaleX = printableW / contentWidthPt;
215
+ const scaleY = printableH / contentHeightPt;
216
+ const factor = Math.min(scaleX, scaleY, 1);
217
+ const scaledW = contentWidthPt * factor;
218
+ const scaledH = contentHeightPt * factor;
219
+ return {
220
+ factor,
221
+ offsetX: marginPt + (printableW - scaledW) / 2,
222
+ offsetY: marginPt + (printableH - scaledH) / 2,
223
+ };
224
+ }
package/src/types.ts ADDED
@@ -0,0 +1,88 @@
1
+ // Shared types consumed by every @nowline/export-* package.
2
+ //
3
+ // Heavy deps (resvg, pdfkit, exceljs) live in the format packages, never
4
+ // here — see specs/handoffs/m2c.md § 1.
5
+
6
+ import type { NowlineFile, ResolveResult } from '@nowline/core';
7
+ import type { PositionedRoadmap } from '@nowline/layout';
8
+
9
+ /** Bundle of inputs every export function consumes. */
10
+ export interface ExportInputs {
11
+ /** Positioned model produced by `layoutRoadmap()`. */
12
+ model: PositionedRoadmap;
13
+ /** Original AST — needed for XLSX / Mermaid / MS Project. */
14
+ ast: NowlineFile;
15
+ /** Include-resolved data — needed for XLSX joins. */
16
+ resolved: ResolveResult;
17
+ /**
18
+ * Display path for the source. Use `'<stdin>'` when piped. Used in
19
+ * footers / metadata; never for filesystem reads.
20
+ */
21
+ sourcePath: string;
22
+ /**
23
+ * Optional pinned timestamp. Exporters that would otherwise call
24
+ * `new Date()` (PDF CreationDate, XLSX Created) MUST take this from
25
+ * `ast.generated` first, then `today`, never `new Date()`.
26
+ */
27
+ today?: Date;
28
+ }
29
+
30
+ // PDF page sizing -----------------------------------------------------------
31
+
32
+ export type PdfPresetName =
33
+ // Imperial / ANSI
34
+ | 'letter' // 8.5 x 11 in (default)
35
+ | 'legal' // 8.5 x 14 in
36
+ | 'tabloid' // 11 x 17 in (ANSI B portrait)
37
+ | 'ledger' // 17 x 11 in (ANSI B landscape)
38
+ // Metric / ISO 216 — A series
39
+ | 'a5'
40
+ | 'a4'
41
+ | 'a3'
42
+ | 'a2'
43
+ | 'a1'
44
+ // Metric / ISO 216 — B series
45
+ | 'b5'
46
+ | 'b4'
47
+ | 'b3';
48
+
49
+ export type PdfLengthUnit = 'pt' | 'in' | 'mm' | 'cm';
50
+
51
+ export interface PdfLength {
52
+ value: number;
53
+ unit: PdfLengthUnit;
54
+ }
55
+
56
+ export type PdfPageSize =
57
+ | { kind: 'preset'; name: PdfPresetName }
58
+ | { kind: 'custom'; width: PdfLength; height: PdfLength }
59
+ | { kind: 'content' };
60
+
61
+ export type PdfOrientation = 'portrait' | 'landscape' | 'auto';
62
+
63
+ // Font resolver -------------------------------------------------------------
64
+
65
+ /** Where the resolver landed when it stopped. */
66
+ export type FontSource = 'flag' | 'env' | 'headless' | 'probe' | 'bundled';
67
+
68
+ export type FontRole = 'sans' | 'mono';
69
+
70
+ export interface ResolvedFont {
71
+ /** Friendly family name, e.g. 'DejaVu Sans', 'SF Pro'. */
72
+ name: string;
73
+ /** Full TTF/OTF bytes, ready for PDFKit / resvg consumption. */
74
+ bytes: Uint8Array;
75
+ /** Where in the resolver chain this font came from. */
76
+ source: FontSource;
77
+ /** Filesystem path; undefined for bundled / synthesized bytes. */
78
+ path?: string;
79
+ /** Face inside a `.ttc` collection, if applicable. */
80
+ face?: string;
81
+ /** True when the loaded font is an OpenType variable font (axes, no fixed instance). */
82
+ isVariableFont?: boolean;
83
+ }
84
+
85
+ export interface ResolvedFontPair {
86
+ sans: ResolvedFont;
87
+ mono: ResolvedFont;
88
+ }
package/src/units.ts ADDED
@@ -0,0 +1,60 @@
1
+ // Length conversions and parsing for PDF page sizing.
2
+ //
3
+ // PDF native unit is the "PostScript point": 1 pt = 1/72 in. Every length
4
+ // resolves to points before the page is laid out — see specs/handoffs/m2c.md
5
+ // § 4 "Unit conversion".
6
+
7
+ import type { PdfLength, PdfLengthUnit } from './types.js';
8
+
9
+ const POINTS_PER_UNIT: Readonly<Record<PdfLengthUnit, number>> = {
10
+ pt: 1,
11
+ in: 72,
12
+ mm: 72 / 25.4, // ≈ 2.83464567
13
+ cm: 72 / 2.54, // ≈ 28.3464567
14
+ };
15
+
16
+ /** Convert a tagged length to PDF points. */
17
+ export function lengthToPoints(length: PdfLength): number {
18
+ return length.value * POINTS_PER_UNIT[length.unit];
19
+ }
20
+
21
+ /** Convert a raw point count back to a tagged length in the requested unit. */
22
+ export function pointsToLength(points: number, unit: PdfLengthUnit): PdfLength {
23
+ return { value: points / POINTS_PER_UNIT[unit], unit };
24
+ }
25
+
26
+ export class LengthParseError extends Error {
27
+ constructor(input: string, reason: string) {
28
+ super(`invalid length "${input}": ${reason}`);
29
+ this.name = 'LengthParseError';
30
+ }
31
+ }
32
+
33
+ const LENGTH_RE = /^(-?\d+(?:\.\d+)?)([a-z]+)$/i;
34
+ const KNOWN_UNITS = new Set<PdfLengthUnit>(['pt', 'in', 'mm', 'cm']);
35
+
36
+ /**
37
+ * Parse a unit-tagged length like `36pt`, `0.5in`, `10mm`, `1cm`.
38
+ *
39
+ * Throws `LengthParseError` on missing unit, unknown unit, non-numeric, zero,
40
+ * or negative values.
41
+ */
42
+ export function parseLength(input: string): PdfLength {
43
+ const trimmed = input.trim();
44
+ if (!trimmed) throw new LengthParseError(input, 'empty');
45
+ const match = LENGTH_RE.exec(trimmed);
46
+ if (!match) {
47
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
48
+ throw new LengthParseError(input, 'missing unit (expected pt, in, mm, or cm)');
49
+ }
50
+ throw new LengthParseError(input, 'expected <number><unit>');
51
+ }
52
+ const unit = match[2].toLowerCase();
53
+ if (!KNOWN_UNITS.has(unit as PdfLengthUnit)) {
54
+ throw new LengthParseError(input, `unknown unit "${unit}"; expected one of pt, in, mm, cm`);
55
+ }
56
+ const value = Number(match[1]);
57
+ if (!Number.isFinite(value)) throw new LengthParseError(input, 'non-numeric value');
58
+ if (value <= 0) throw new LengthParseError(input, 'must be positive');
59
+ return { value, unit: unit as PdfLengthUnit };
60
+ }