@rse/specbook 1.3.2 → 1.4.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 (45) hide show
  1. package/README.md +5 -2
  2. package/dst/specbook-api.js +7 -5
  3. package/dst/specbook-cli.js +0 -0
  4. package/dst/specbook-cmd-export.js +2 -2
  5. package/dst/specbook-coverage.js +1 -1
  6. package/dst/specbook-diagram.d.ts +1 -1
  7. package/dst/specbook-diagram.js +9 -3
  8. package/dst/specbook-export-ast.d.ts +1 -1
  9. package/dst/specbook-export-ast.js +21 -4
  10. package/dst/specbook-export-code.d.ts +14 -0
  11. package/dst/specbook-export-code.js +155 -0
  12. package/dst/specbook-export-common.d.ts +4 -2
  13. package/dst/specbook-export-common.js +110 -6
  14. package/dst/specbook-export-diagram.d.ts +15 -0
  15. package/dst/specbook-export-diagram.js +211 -0
  16. package/dst/specbook-export-html-fold.d.ts +20 -0
  17. package/dst/specbook-export-html-fold.js +458 -0
  18. package/dst/specbook-export-html-render.d.ts +51 -0
  19. package/dst/specbook-export-html-render.js +749 -0
  20. package/dst/specbook-export-html-search.js +6 -5
  21. package/dst/specbook-export-html-template.d.ts +21 -0
  22. package/dst/specbook-export-html-template.js +282 -0
  23. package/dst/specbook-export-html-widget.d.ts +12 -0
  24. package/dst/specbook-export-html-widget.js +605 -0
  25. package/dst/specbook-export-html.css +325 -50
  26. package/dst/specbook-export-html.js +62 -1931
  27. package/dst/specbook-export-image.d.ts +2 -0
  28. package/dst/specbook-export-image.js +161 -9
  29. package/dst/specbook-export-md.js +60 -12
  30. package/dst/specbook-format-schema.d.ts +1 -1
  31. package/dst/specbook-format-schema.js +1 -1
  32. package/dst/specbook-format-spec.d.ts +1 -0
  33. package/dst/specbook-format-spec.js +1 -0
  34. package/dst/specbook-format.md +169 -17
  35. package/dst/specbook-format.yaml +224 -23
  36. package/dst/specbook-ignore.js +2 -0
  37. package/dst/specbook-link.js +21 -5
  38. package/dst/specbook-parse-common.d.ts +24 -0
  39. package/dst/specbook-parse-common.js +137 -10
  40. package/dst/specbook-parse-semantic.js +2 -0
  41. package/dst/specbook-parse-syntax.js +81 -20
  42. package/dst/specbook-parse.js +1 -1
  43. package/dst/specbook-theme.d.ts +1 -0
  44. package/dst/specbook-theme.js +2 -0
  45. package/package.json +13 -7
package/README.md CHANGED
@@ -83,6 +83,8 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
83
83
  The HTML export is a self-contained single document with a title page,
84
84
  a table-of-contents (also as a slide-in side panel for quick navigation),
85
85
  a diagram-of-contents, a fuzzy full-text search, embedded images,
86
+ embedded Mermaid and D2 diagrams (inline or as files, in the theme colors),
87
+ embedded PDF pages,
86
88
  reference coverage tables (e.g. the share of the use cases, scenarios,
87
89
  requirements, and rules the test cases verify), a
88
90
  scroll progress meter (which also scrolls back to the top on click),
@@ -242,7 +244,8 @@ Options:
242
244
  Omit content aspects (comma-separated, repeatable) from the HTML and
243
245
  PDF outputs at generation time, instead of just leaving them foldable
244
246
  in the browser. The aspects match the folding controls of the HTML
245
- export: `diagram:graph`, `diagram:hub`, and `diagram:grid` omit all
247
+ export: `diagram:graph`, `diagram:hub`, `diagram:grid`,
248
+ `diagram:code` (Mermaid/D2), and `diagram:image` (images and PDF pages) omit all
246
249
  diagrams of a type, `diagram:1` (alias: `diagram`), `diagram:2`, and
247
250
  `diagram:3` omit all diagrams from an object tree nesting level on
248
251
  (level 1 hence all, including the "Diagram of Contents" page), and
@@ -419,7 +422,7 @@ application, based on **SpecBook**'s built-in "standard" schema:
419
422
  ### Sources
420
423
 
421
424
  - [Specification](smp/broadcast/)
422
- - [Schema](src/specbook-format.d/)
425
+ - [Schema](src/specbook-format-schema.d/)
423
426
 
424
427
  ### Generation
425
428
 
@@ -125,10 +125,11 @@ export class SpecBook {
125
125
  /* render an already parsed specification into the requested formats
126
126
  (strict: any error diagnostic prevents the export, as a partial or
127
127
  invalid specification must never be emitted, while the warnings
128
- are just surfaced as notices), where "rendering" carries the
129
- rendering options (the live preview script, the omitted aspects)
130
- and "rebase" names, per requested format, the directory the image
131
- references of the normalized Markdown are re-based onto */
128
+ are just surfaced as unconditional verbose messages), where
129
+ "rendering" carries the rendering options (the live preview
130
+ script, the omitted aspects) and "rebase" names, per requested
131
+ format, the directory the image references of the normalized
132
+ Markdown are re-based onto */
132
133
  async renderFormats(result, requested, verbose, rendering, rebase) {
133
134
  if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
134
135
  throw new Error("invalid specification:\n" +
@@ -163,11 +164,12 @@ export class SpecBook {
163
164
  const requested = options.formats ?? ["json"];
164
165
  const omit = parseOmit(options.omit);
165
166
  const project = this.project(options, verbose);
167
+ const config = await this.configFiles(project.config);
166
168
  /* a missing browser is an environment problem, so let the PDF
167
169
  export fail before the specification is even parsed */
168
170
  if (requested.includes("pdf"))
169
171
  await requireBrowser(verbose);
170
- const result = lint({ config: await this.configFiles(project.config),
172
+ const result = lint({ config,
171
173
  basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
172
174
  return this.renderFormats(result, requested, verbose, { realtime: options.realtime === true, omit }, this.rebaseOf(options));
173
175
  }
File without changes
@@ -10,7 +10,7 @@ import * as fs from "node:fs";
10
10
  import { watch } from "chokidar5";
11
11
  import { minify } from "@swc/html";
12
12
  import { documentTitle, documentLogo, documentCharset, documentThemeTone, subsetStylesheet, documentPaperSize, paperStylesheet, charsetCodepoints, parseOmit, omitAspects } from "./specbook-export-common.js";
13
- import { themeColors, themeStylesheet, themeMapping } from "./specbook-theme.js";
13
+ import { themeColors, themeToneDefault, themeStylesheet, themeMapping } from "./specbook-theme.js";
14
14
  import { renderAst } from "./specbook-export-ast.js";
15
15
  import { renderMarkdown } from "./specbook-export-md.js";
16
16
  import { renderHtml, htmlOutline, titlePageObject } from "./specbook-export-html.js";
@@ -169,7 +169,7 @@ const renderFormat = async (specification, format, verbose, config, options = {}
169
169
  verbose(`subsetting embedded fonts to charset "${literal(charset)}"`);
170
170
  /* the theme tone (THEME-TONE) drives the layer-1 color spread
171
171
  variables and the PDF decoration colors */
172
- const tone = documentThemeTone(specification) ?? "#336699";
172
+ const tone = documentThemeTone(specification) ?? themeToneDefault;
173
173
  verbose(`generating theme color spreads (tone "${literal(tone)}")`);
174
174
  const colors = themeColors(tone);
175
175
  /* the paper size (PAPER-SIZE) drives the PDF page setup and the
@@ -17,7 +17,7 @@ const referrerIndex = (index) => {
17
17
  const chain = chainOf(index, object);
18
18
  const texts = [object.name, ...object.properties.map((p) => p.value)];
19
19
  if (object.description !== undefined)
20
- texts.push(object.description.description, object.description.rationale ?? "");
20
+ texts.push(object.description.description, object.description.rationale ?? "", object.description.elaboration ?? "");
21
21
  for (const text of texts)
22
22
  for (const m of plainText(text).matchAll(referenceRegex)) {
23
23
  const target = resolveUnique(index, m[1].trim(), object).target;
@@ -15,7 +15,7 @@ export interface DiagramResult {
15
15
  }
16
16
  export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
17
17
  export declare const omittedDiagrams: (specification: Spec, config: Schema, omit: Set<OmitAspect>) => Set<SpecObject>;
18
- export declare const renderDiagrams: (specification: Spec, config: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<SpecObject, DiagramResult & {
18
+ export declare const renderDiagrams: (specification: Spec, config: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<SpecObject, DiagramResult & {
19
19
  svg: string;
20
20
  }>>;
21
21
  export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -180,6 +180,8 @@ const deriveReferenceEdges = (ctx, diagram, nodes, nodeSet) => {
180
180
  texts.push({ text: object.description.description });
181
181
  if (object.description.rationale !== undefined)
182
182
  texts.push({ text: object.description.rationale });
183
+ if (object.description.elaboration !== undefined)
184
+ texts.push({ text: object.description.elaboration });
183
185
  }
184
186
  for (const { text, name } of texts)
185
187
  for (const m of plainText(text).matchAll(referenceRegex)) {
@@ -512,7 +514,8 @@ const deriveNodes = (index, object, diagram, errors) => {
512
514
  const nodes = new Array();
513
515
  const walk = (o) => {
514
516
  nodes.push(o);
515
- o.children.forEach(walk);
517
+ for (const child of o.children)
518
+ walk(child);
516
519
  };
517
520
  walk(object);
518
521
  return nodes;
@@ -600,14 +603,17 @@ const deriveDiagram = (ctx, object, diagram) => {
600
603
  }
601
604
  /* the "onlyConnected" filtering keeps only the nodes with at
602
605
  least one incident edge (sensible for "graph" diagrams only),
603
- and the container nodes, whose members connect them */
606
+ and the containers enclosing such a node */
604
607
  if (diagram.onlyConnected === true && type === "graph") {
605
608
  const connected = new Set();
606
609
  for (const edge of edges) {
607
610
  connected.add(edge.source);
608
611
  connected.add(edge.target);
609
612
  }
610
- nodes = nodes.filter((node) => connected.has(node) || nesting.containers.has(node));
613
+ for (const node of Array.from(connected))
614
+ for (let p = nesting.parentOf.get(node); p !== undefined; p = nesting.parentOf.get(p))
615
+ connected.add(p);
616
+ nodes = nodes.filter((node) => connected.has(node));
611
617
  }
612
618
  if (errors.length > 0)
613
619
  return { errors };
@@ -2,4 +2,4 @@ import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
3
  import type { Verbose } from "./specbook-verbose.js";
4
4
  export type AstFormat = "json" | "json5" | "yaml" | "toon";
5
- export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Buffer>;
5
+ export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Buffer>;
@@ -10,17 +10,34 @@ import { specDiagrams, omittedDiagrams } from "./specbook-diagram.js";
10
10
  import { specCoverage } from "./specbook-coverage.js";
11
11
  import { buildLinkIndex } from "./specbook-link.js";
12
12
  import { collectSchemas } from "./specbook-parse-semantic.js";
13
- import { isTitleObject } from "./specbook-export-common.js";
14
- import { optimizeImages } from "./specbook-export-image.js";
13
+ import { isTitleObject, imageDark } from "./specbook-export-common.js";
14
+ import { optimizeImages, analyzeImages, isDocument } from "./specbook-export-image.js";
15
+ import { renderEmbeddedDiagrams, embeddedSource, diagramKey } from "./specbook-export-diagram.js";
16
+ import { darkMark, markDark } from "./specbook-parse-common.js";
15
17
  /* render the specification AST into a serialization format,
16
18
  where "omit" names the aspects whose diagrams are left out */
17
19
  export const renderAst = async (specification, format, config, verbose, omit = new Set()) => {
18
20
  /* reduce the specification to plain JSON values (ISO date strings),
19
21
  with the embedded images (by far the bulk of the export)
20
- optimized like the ones of the HTML export */
22
+ optimized like the ones of the HTML export and the embedded
23
+ diagram source files replaced by their rendered SVGs (the light
24
+ variant), an image (a PDF page as its converted SVG) marked as
25
+ inverted where the "auto" detection inverts it, and an omitted or
26
+ failed diagram or PDF page leaving an empty entry, like an
27
+ unreadable file) */
21
28
  const optimized = await optimizeImages(specification, false, verbose);
29
+ const rasters = await analyzeImages(specification, verbose);
30
+ const sources = await renderEmbeddedDiagrams(specification, verbose, omit);
22
31
  const plain = JSON.parse(JSON.stringify(specification, (key, value) => key !== "embedding" ? value :
23
- value.map((content) => optimized.get(content) ?? content)));
32
+ value.map((content) => {
33
+ const diagram = embeddedSource(content);
34
+ if (diagram === undefined) {
35
+ const image = optimized.get(content) ?? (isDocument(content) ? "" : content);
36
+ return image !== "" && darkMark(image).dark === undefined
37
+ && imageDark(content, rasters) === "invert" ? markDark(image, "invert") : image;
38
+ }
39
+ return sources.get(diagramKey(diagram.language, diagram.source))?.light ?? "";
40
+ })));
24
41
  /* attach the Gradia specs of the diagram-configured objects as
25
42
  "diagram" fields onto the corresponding plain object nodes
26
43
  (an invalid diagram situation omits the diagram, as it is
@@ -0,0 +1,14 @@
1
+ import { type Tokens } from "marked";
2
+ import type { Spec } from "./specbook-format-spec.js";
3
+ import { type Verbose } from "./specbook-verbose.js";
4
+ export interface Listing {
5
+ lang: string;
6
+ source: string;
7
+ start: number;
8
+ mark: string;
9
+ }
10
+ export type ListingRenderer = (listing: Listing) => string;
11
+ export declare const embeddedListing: (content: string) => Listing | undefined;
12
+ export declare const fencedListing: (token: Tokens.Code) => Listing | undefined;
13
+ export declare const listingFence: (listing: Listing) => string;
14
+ export declare const prepareListings: (specification: Spec, verbose?: Verbose) => Promise<ListingRenderer>;
@@ -0,0 +1,155 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { marked } from "marked";
7
+ import { codeLanguage, fenceInfo, markedLines } from "./specbook-parse-common.js";
8
+ import { escapeHtml } from "./specbook-export-common.js";
9
+ import { literal } from "./specbook-verbose.js";
10
+ /* decode an embedded source code file, which the parser loads as a base64
11
+ data: URL of its code MIME type (undefined for any other content) */
12
+ export const embeddedListing = (content) => {
13
+ const m = content.match(/^data:text\/x-code;lang=([^;,]+);start=(\d+)(?:;mark=([^;,]+))?;base64,(.*)$/s);
14
+ return m !== null ? {
15
+ lang: m[1],
16
+ source: Buffer.from(m[4], "base64").toString("utf8"),
17
+ start: Number(m[2]),
18
+ mark: decodeURIComponent(m[3] ?? "")
19
+ } : undefined;
20
+ };
21
+ /* determine the code listing of a fenced code block (undefined for a
22
+ block of no or an unknown language, and for invalid parameters, which
23
+ the parser reports) */
24
+ export const fencedListing = (token) => {
25
+ const info = fenceInfo(token.lang);
26
+ const lang = codeLanguage(info.lang);
27
+ if (lang === undefined || info.error !== undefined)
28
+ return undefined;
29
+ return { lang, source: token.text, start: Number(info.params.start ?? "1"), mark: info.params.mark ?? "" };
30
+ };
31
+ /* render a code listing back into a fenced code block, with its
32
+ parameters deviating from the defaults (the fence outgrowing the
33
+ longest backtick run of the source code) */
34
+ export const listingFence = (listing) => {
35
+ const run = Math.max(2, ...Array.from(listing.source.matchAll(/`+/g), (m) => m[0].length));
36
+ const fence = "`".repeat(run + 1);
37
+ const params = [listing.start !== 1 ? ` start=${listing.start}` : "",
38
+ listing.mark !== "" ? ` mark=${listing.mark}` : ""].join("");
39
+ return `${fence}${listing.lang}${params}\n${listing.source}\n${fence}`;
40
+ };
41
+ /* the marker theme of the syntax highlighting: it maps the TextMate
42
+ scopes onto marker colors, which the rendering turns into the token
43
+ classes, so no color enters the HTML and the stylesheet alone colors
44
+ the tokens (the more specific scope wins, e.g. the string quotes
45
+ over the other punctuation, and the code embedded into a string
46
+ turns regular again) */
47
+ const tokenClasses = {
48
+ "#000001": "code-keyword",
49
+ "#000002": "code-literal",
50
+ "#000003": "code-comment"
51
+ };
52
+ const markerTheme = {
53
+ name: "specbook",
54
+ type: "light",
55
+ settings: [
56
+ { settings: { foreground: "#000000", background: "#ffffff" } },
57
+ { scope: ["keyword", "storage", "punctuation", "meta.brace", "variable.language", "entity.name.tag"],
58
+ settings: { foreground: "#000001" } },
59
+ { scope: ["string", "constant", "support.constant", "punctuation.definition.string"],
60
+ settings: { foreground: "#000002" } },
61
+ { scope: ["comment", "punctuation.definition.comment"],
62
+ settings: { foreground: "#000003" } },
63
+ { scope: ["meta.template.expression", "meta.embedded"],
64
+ settings: { foreground: "#000000" } }
65
+ ]
66
+ };
67
+ /* the syntax highlighter, created on first use (with the JavaScript
68
+ regular expression engine, so no WebAssembly is needed) and loading
69
+ the grammars of the languages on demand */
70
+ let highlighter = null;
71
+ /* render a code listing into a "pre" block: one block-level line per
72
+ source line (marked ones carrying a class), whose number the stylesheet
73
+ draws from a counter (so it neither is copied nor searched), and the
74
+ highlighted tokens of a loaded language as token class spans (adjacent
75
+ tokens of the same class merged) */
76
+ const renderListing = (shiki, listing) => {
77
+ const tokens = shiki !== null && shiki.getLoadedLanguages().includes(listing.lang) ?
78
+ shiki.codeToTokensBase(listing.source, { lang: listing.lang, theme: markerTheme.name })
79
+ .map((line) => line.map((token) => ({ content: token.content, cls: tokenClasses[(token.color ?? "").toLowerCase()] }))) :
80
+ listing.source.split(/\r?\n/).map((line) => [{ content: line, cls: undefined }]);
81
+ const marks = markedLines(listing.mark);
82
+ const digits = String(listing.start + tokens.length - 1).length;
83
+ const lines = tokens.map((line, i) => {
84
+ const spans = new Array();
85
+ for (const token of line) {
86
+ const prev = spans.at(-1);
87
+ if (prev !== undefined && prev.cls === token.cls)
88
+ prev.content += token.content;
89
+ else
90
+ spans.push({ ...token });
91
+ }
92
+ return `<span class="line${marks.has(listing.start + i) ? " marked" : ""}">` +
93
+ spans.map((span) => span.cls !== undefined ?
94
+ `<span class="${span.cls}">${escapeHtml(span.content)}</span>` : escapeHtml(span.content)).join("") +
95
+ "</span>";
96
+ });
97
+ return `<pre class="listing" style="--listing-start: ${listing.start}; --listing-digits: ${digits}">` +
98
+ `<code>${lines.join("")}</code></pre>`;
99
+ };
100
+ /* collect the languages of the code listings of an object and its
101
+ descendants: the fenced code blocks of a known language inside its
102
+ description statement and elaboration and the source code files
103
+ embedded into its description and property values */
104
+ const collect = (object, langs) => {
105
+ for (const text of [object.description?.description ?? "", object.description?.elaboration ?? ""])
106
+ marked.walkTokens(marked.lexer(text), (token) => {
107
+ const listing = token.type === "code" ? fencedListing(token) : undefined;
108
+ if (listing !== undefined)
109
+ langs.add(listing.lang);
110
+ });
111
+ const contents = [...object.description?.embedding ?? [],
112
+ ...object.properties.flatMap((property) => property.embedding ?? [])];
113
+ for (const content of contents) {
114
+ const listing = embeddedListing(content);
115
+ if (listing !== undefined)
116
+ langs.add(listing.lang);
117
+ }
118
+ for (const child of object.children)
119
+ collect(child, langs);
120
+ };
121
+ /* prepare the rendering of the code listings of a specification: the
122
+ syntax highlighter is loaded (on first use) together with the grammars
123
+ of all languages used, as the Markdown rendering is synchronous, and
124
+ a failure to load is surfaced as a notice only, leaving the listings
125
+ of the affected languages unhighlighted */
126
+ export const prepareListings = async (specification, verbose) => {
127
+ const langs = new Set();
128
+ for (const artifact of specification.artifacts)
129
+ for (const object of artifact.objects)
130
+ collect(object, langs);
131
+ if (langs.size === 0)
132
+ return (listing) => renderListing(null, listing);
133
+ let shiki = null;
134
+ try {
135
+ highlighter ??= Promise.all([import("shiki/core"), import("shiki/engine/javascript")])
136
+ .then(([{ createHighlighterCore }, { createJavaScriptRegexEngine }]) => createHighlighterCore({ themes: [markerTheme], langs: [], engine: createJavaScriptRegexEngine() }));
137
+ shiki = await highlighter;
138
+ }
139
+ catch (err) {
140
+ highlighter = null;
141
+ verbose?.("loading syntax highlighter failed: " + (err instanceof Error ? err.message : String(err)));
142
+ }
143
+ if (shiki !== null) {
144
+ const { bundledLanguages } = await import("shiki/langs");
145
+ const missing = Array.from(langs).filter((lang) => !shiki.getLoadedLanguages().includes(lang));
146
+ for (const lang of missing)
147
+ await shiki.loadLanguage(bundledLanguages[lang]).catch((err) => {
148
+ verbose?.(`loading syntax highlighting of language "${literal(lang)}" failed: ` +
149
+ (err instanceof Error ? err.message : String(err)));
150
+ });
151
+ verbose?.(`highlighting code listings of ${literal(langs.size)} language(s) ` +
152
+ `(${literal(missing.length)} newly loaded)`);
153
+ }
154
+ return (listing) => renderListing(shiki, listing);
155
+ };
@@ -1,10 +1,10 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
- import { embeddingThemes } from "./specbook-parse-common.js";
2
+ import { embeddingThemes, type DarkMark } from "./specbook-parse-common.js";
3
3
  export declare const escapeHtml: (text: string) => string;
4
4
  export declare const stylesheet: () => string;
5
5
  export declare const searchScript: () => string;
6
6
  export declare const fallbackLogo: (theme: (typeof embeddingThemes)[number]) => string;
7
- export declare const omitAspects: readonly ["diagram:graph", "diagram:hub", "diagram:grid", "diagram:1", "diagram:2", "diagram:3", "text:long"];
7
+ export declare const omitAspects: readonly ["diagram:graph", "diagram:hub", "diagram:grid", "diagram:code", "diagram:image", "diagram:listing", "diagram:1", "diagram:2", "diagram:3", "text:long"];
8
8
  export type OmitAspect = typeof omitAspects[number];
9
9
  export declare const parseOmit: (omit?: string[]) => Set<OmitAspect>;
10
10
  export interface ExportOptions {
@@ -38,4 +38,6 @@ export declare const documentTitle: (specification: Spec) => {
38
38
  title: string;
39
39
  subtitle?: string;
40
40
  };
41
+ export declare const svgInvertible: (svg: string) => boolean;
42
+ export declare const imageDark: (content: string, rasters?: Map<string, boolean>) => DarkMark;
41
43
  export declare const documentLogo: (specification: Spec) => string;
@@ -4,7 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import fs from "node:fs";
7
- import { embeddingThemes } from "./specbook-parse-common.js";
7
+ import { embeddingThemes, darkMark } from "./specbook-parse-common.js";
8
8
  import { plainText } from "./specbook-link.js";
9
9
  /* escape a text for embedding into template HTML (text and attributes) */
10
10
  export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
@@ -19,9 +19,13 @@ export const searchScript = () => fs.readFileSync(new URL("specbook-export-html-
19
19
  export const fallbackLogo = (theme) => "data:image/svg+xml;base64," +
20
20
  fs.readFileSync(new URL(`specbook-export-logo-${theme}.svg`, import.meta.url)).toString("base64");
21
21
  /* the content aspects an export can omit, each matching the set of a
22
- folding control of the HTML export: the diagrams of a type, the
23
- diagrams from an object tree nesting level on, and the long texts */
22
+ folding control of the HTML export: the diagrams of a type (the
23
+ derived Gradia ones, the embedded Mermaid/D2 ones as "code", the
24
+ embedded images and PDF pages as "image", and the code listings as
25
+ "listing"), the diagrams from an object tree nesting level on, and the
26
+ long texts */
24
27
  export const omitAspects = ["diagram:graph", "diagram:hub", "diagram:grid",
28
+ "diagram:code", "diagram:image", "diagram:listing",
25
29
  "diagram:1", "diagram:2", "diagram:3", "text:long"];
26
30
  /* parse the (comma-separated) aspects to omit, where the
27
31
  plain "diagram" is the alias of "diagram:1", hence of all diagrams */
@@ -129,7 +133,7 @@ export const paperStylesheet = (paper) => {
129
133
  const avail = setup.height - setup.margin.top - setup.margin.bottom;
130
134
  return "@media print {\n" +
131
135
  "div.diagram { break-inside: avoid; }\n" +
132
- `div.diagram svg { max-height: calc(${paperLength(setup, avail)} - ${headingReserve}rem);` +
136
+ `div.diagram svg, div.diagram img { max-height: calc(${paperLength(setup, avail)} - ${headingReserve}rem);` +
133
137
  " width: auto; height: auto; }\n" +
134
138
  `nav.doc div.diagram svg { max-height: calc(${paperLength(setup, avail)} - ${docHeadingReserve}rem); }\n` +
135
139
  "}\n";
@@ -223,6 +227,105 @@ export const documentTitle = (specification) => {
223
227
  subtitle: subtitle !== undefined ? plainText(subtitle) : undefined
224
228
  };
225
229
  };
230
+ /* the luma (0-1, of the gamma-encoded channels) of a CSS color given as
231
+ "#rgb", "#rrggbb", "rgb(r, g, b)", "white", or "black" (undefined for
232
+ any other one) */
233
+ const luminance = (color) => {
234
+ const value = color.trim().toLowerCase();
235
+ const named = value === "white" ? "#ffffff" : value === "black" ? "#000000" : value;
236
+ const hex = named.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
237
+ const rgb = named.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
238
+ const parts = hex !== null ?
239
+ (hex[1].length === 3 ? hex[1].split("").map((c) => c + c) : hex[1].match(/../g) ?? [])
240
+ .map((c) => parseInt(c, 16)) :
241
+ rgb !== null ? rgb.slice(1, 4).map(Number) : undefined;
242
+ return parts !== undefined ? (0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2]) / 255 : undefined;
243
+ };
244
+ /* whether an SVG image is most likely dark ink on a transparent or light
245
+ canvas and hence safe to invert on the dark theme ("dark=auto"): it
246
+ neither adapts itself (via "light-dark()" colors, "prefers-color-scheme"
247
+ rules, or a "color-scheme" declaration), nor embeds a raster image
248
+ (which would turn into a negative), nor paints a background of its own
249
+ which is not light (by the style of its root element or by the topmost
250
+ of the leading shapes covering the whole canvas -- rectangles or
251
+ rectangular paths, as a rendered PDF page paints its background over
252
+ the white paper of PDF.js --, an unknown color counting as not light) */
253
+ export const svgInvertible = (svg) => {
254
+ if (/light-dark\(|prefers-color-scheme|color-scheme|<image\b/.test(svg))
255
+ return false;
256
+ const root = svg.match(/<svg\b[^>]*>/)?.[0] ?? "";
257
+ const light = (color) => (luminance(color ?? "#000000") ?? 0) >= 0.5;
258
+ const back = root.match(/background(?:-color)?\s*:\s*([^;"]+)/)?.[1];
259
+ if (back !== undefined && !/^\s*(?:none|transparent)\s*$/.test(back) && !light(back))
260
+ return false;
261
+ /* the canvas size (in the user units of the viewBox, if any) */
262
+ const attr = (element, name) => element.match(new RegExp(`\\s${name}="([^"]*)"`))?.[1] ??
263
+ element.match(new RegExp(`[\\s;"]${name}\\s*:\\s*([^;"]+)`))?.[1];
264
+ const box = attr(root, "viewBox")?.trim().split(/[\s,]+/).map(Number) ?? [];
265
+ const width = box[2] || parseFloat(attr(root, "width") ?? "");
266
+ const height = box[3] || parseFloat(attr(root, "height") ?? "");
267
+ /* whether a shape covers the whole canvas: the corner points of a
268
+ rectangle or of a path of straight absolute segments only, mapped
269
+ through its "scale()" or "matrix()" transform (if any) */
270
+ const covers = (kind, element) => {
271
+ const num = (name) => parseFloat(attr(element, name) ?? "0") || 0;
272
+ let points;
273
+ if (kind === "rect") {
274
+ if (attr(element, "width") === "100%" && attr(element, "height") === "100%")
275
+ return true;
276
+ const [x, y] = [num("x"), num("y")];
277
+ points = [[x, y], [x + num("width"), y + num("height")]];
278
+ }
279
+ else {
280
+ const d = attr(element, "d") ?? "";
281
+ if (kind !== "path" || !/^[\sMLHVZ\d.,-]*$/.test(d))
282
+ return false;
283
+ points = [];
284
+ for (const [, cmd, args] of d.matchAll(/([MLHVZ])([^MLHVZ]*)/g)) {
285
+ const n = args.trim().split(/[\s,]+/).filter((a) => a !== "").map(Number);
286
+ const last = points.at(-1) ?? [0, 0];
287
+ if (cmd === "H")
288
+ points.push(...n.map((x) => [x, last[1]]));
289
+ else if (cmd === "V")
290
+ points.push(...n.map((y) => [last[0], y]));
291
+ else
292
+ for (let k = 0; k + 1 < n.length; k += 2)
293
+ points.push([n[k], n[k + 1]]);
294
+ }
295
+ }
296
+ const t = attr(element, "transform")?.trim() ?? "";
297
+ const f = t.match(/^(matrix|scale)\(([^)]*)\)$/);
298
+ const v = f?.[2].trim().split(/[\s,]+/).map(Number) ?? [];
299
+ const m = t === "" ? [1, 0, 0, 1, 0, 0] :
300
+ f?.[1] === "matrix" && v.length === 6 ? v :
301
+ f?.[1] === "scale" ? [v[0], 0, 0, v[1] ?? v[0], 0, 0] : undefined;
302
+ if (m === undefined || points.length === 0)
303
+ return false;
304
+ const xs = points.map(([x, y]) => m[0] * x + m[2] * y + m[4]);
305
+ const ys = points.map(([x, y]) => m[1] * x + m[3] * y + m[5]);
306
+ return Math.min(...xs) <= 1 && Math.min(...ys) <= 1
307
+ && Math.max(...xs) >= width - 1 && Math.max(...ys) >= height - 1;
308
+ };
309
+ /* the topmost leading canvas-covering shape (outside of any
310
+ definition) forms the background */
311
+ let fill;
312
+ const body = svg.slice(svg.indexOf(root) + root.length)
313
+ .replace(/<(clipPath|defs|mask|pattern|symbol)\b[\s\S]*?<\/\1>/g, "");
314
+ for (const shape of body.matchAll(/<(rect|path|circle|ellipse|line|polyline|polygon|text|use)\b[^>]*>/g)) {
315
+ if (!covers(shape[1], shape[0]))
316
+ break;
317
+ fill = attr(shape[0], "fill") ?? "#000000";
318
+ }
319
+ return fill === undefined || fill === "none" || fill === "transparent" || light(fill);
320
+ };
321
+ /* the treatment of an embedded image on the dark theme: its explicit
322
+ mark, else the "auto" detection (for a raster image or a PDF page the
323
+ judgement of "analyzeImages", none where absent) */
324
+ export const imageDark = (content, rasters) => {
325
+ const { content: plain, dark } = darkMark(content);
326
+ return dark ?? ((plain.startsWith("data:") ? rasters?.get(plain) === true : svgInvertible(plain)) ?
327
+ "invert" : "none");
328
+ };
226
329
  /* determine the document logo as a self-contained data: URL, taken from the
227
330
  first embedded image of the LOGO property of the title object -- the light
228
331
  variant of a "{theme}" reference -- and falling back onto the built-in
@@ -233,6 +336,7 @@ export const documentLogo = (specification) => {
233
336
  ?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
234
337
  if (content === undefined || content === "")
235
338
  return fallbackLogo("light");
236
- return content.startsWith("data:") ? content :
237
- `data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;
339
+ const plain = darkMark(content).content;
340
+ return plain.startsWith("data:") ? plain :
341
+ `data:image/svg+xml;base64,${Buffer.from(plain, "utf8").toString("base64")}`;
238
342
  };
@@ -0,0 +1,15 @@
1
+ import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
+ import { type OmitAspect } from "./specbook-export-common.js";
3
+ import { type ThemeStyle } from "./specbook-theme.js";
4
+ import { type Verbose } from "./specbook-verbose.js";
5
+ export type DiagramLanguage = "mermaid" | "d2";
6
+ export type DiagramVariants = Record<ThemeStyle, string>;
7
+ export declare const fenceLanguage: (lang: string | undefined) => DiagramLanguage | undefined;
8
+ export declare const embeddedSource: (content: string) => {
9
+ language: DiagramLanguage;
10
+ source: string;
11
+ } | undefined;
12
+ export declare const diagramKey: (language: DiagramLanguage, source: string) => string;
13
+ export declare const omittedDiagram: (language: DiagramLanguage | "image" | "listing", level: number, omit: Set<OmitAspect>) => boolean;
14
+ export declare const objectLevels: (specification: Spec) => Map<SpecObject, number>;
15
+ export declare const renderEmbeddedDiagrams: (specification: Spec, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<string, DiagramVariants>>;