@brianmsm/quarto-bento 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/qbento-npm.mjs +62 -0
- package/distribution/LICENSE +21 -0
- package/distribution/PACKAGING.md +59 -0
- package/distribution/bin/qbento +5 -0
- package/distribution/bin/qbento.cmd +4 -0
- package/distribution/manifest.json +720 -0
- package/distribution/runtime/LICENSE +21 -0
- package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
- package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
- package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
- package/distribution/runtime/_extensions/qbento/post.ts +2 -0
- package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
- package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
- package/distribution/runtime/src/bridge/browser.js +168 -0
- package/distribution/runtime/src/bridge/isolation.ts +55 -0
- package/distribution/runtime/src/browser/chromium.ts +218 -0
- package/distribution/runtime/src/cli.ts +219 -0
- package/distribution/runtime/src/core/agent.ts +565 -0
- package/distribution/runtime/src/core/annotations.ts +384 -0
- package/distribution/runtime/src/core/capture.ts +203 -0
- package/distribution/runtime/src/core/component-actions.ts +550 -0
- package/distribution/runtime/src/core/component-frames.ts +59 -0
- package/distribution/runtime/src/core/component-sync.ts +290 -0
- package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
- package/distribution/runtime/src/core/design-registry.ts +287 -0
- package/distribution/runtime/src/core/graph-relations.ts +354 -0
- package/distribution/runtime/src/core/graph-scope.ts +63 -0
- package/distribution/runtime/src/core/graph-sync.ts +228 -0
- package/distribution/runtime/src/core/io.ts +27 -0
- package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
- package/distribution/runtime/src/core/layouts.ts +245 -0
- package/distribution/runtime/src/core/project.ts +759 -0
- package/distribution/runtime/src/core/reconcile.ts +384 -0
- package/distribution/runtime/src/core/recovery.ts +1255 -0
- package/distribution/runtime/src/core/slide-actions.ts +158 -0
- package/distribution/runtime/src/core/source-scope.ts +50 -0
- package/distribution/runtime/src/core/sync.ts +2423 -0
- package/distribution/runtime/src/core/widgets.ts +300 -0
- package/distribution/runtime/src/integration/install.ts +98 -0
- package/distribution/runtime/src/integration/invocation.ts +100 -0
- package/distribution/runtime/src/integration/quarto.ts +457 -0
- package/distribution/runtime/src/render/assets.ts +66 -0
- package/distribution/runtime/src/render/capture.lua +8 -0
- package/distribution/runtime/src/render/chart.ts +165 -0
- package/distribution/runtime/src/render/components.ts +199 -0
- package/distribution/runtime/src/render/design.ts +820 -0
- package/distribution/runtime/src/render/diagnostics.ts +36 -0
- package/distribution/runtime/src/render/graphs.ts +747 -0
- package/distribution/runtime/src/render/html.ts +82 -0
- package/distribution/runtime/src/render/images.ts +268 -0
- package/distribution/runtime/src/render/inline.ts +259 -0
- package/distribution/runtime/src/render/kpi.ts +385 -0
- package/distribution/runtime/src/render/lists.ts +183 -0
- package/distribution/runtime/src/render/math.ts +413 -0
- package/distribution/runtime/src/render/measure.ts +185 -0
- package/distribution/runtime/src/render/native-layout.ts +174 -0
- package/distribution/runtime/src/render/producer-capture.ts +319 -0
- package/distribution/runtime/src/render/producer-environment.R +49 -0
- package/distribution/runtime/src/render/producer-environment.ts +96 -0
- package/distribution/runtime/src/render/producer-profile.R +91 -0
- package/distribution/runtime/src/render/producer-site-profile.R +18 -0
- package/distribution/runtime/src/render/project.ts +1806 -0
- package/distribution/runtime/src/render/quarto.ts +127 -0
- package/distribution/runtime/src/render/registry.ts +233 -0
- package/distribution/runtime/src/render/resources.ts +292 -0
- package/distribution/runtime/src/render/shell.ts +110 -0
- package/distribution/runtime/src/render/source-inline.ts +169 -0
- package/distribution/runtime/src/render/table.ts +220 -0
- package/distribution/runtime/src/render/text-overflow.ts +208 -0
- package/distribution/runtime/src/render/types.ts +71 -0
- package/distribution/runtime/src/render/validate.ts +572 -0
- package/distribution/runtime/src/rich/project.ts +117 -0
- package/distribution/runtime/src/rich/table.ts +374 -0
- package/distribution/runtime/src/source/annotations.ts +490 -0
- package/distribution/runtime/src/source/attributes.ts +415 -0
- package/distribution/runtime/src/source/chart.ts +165 -0
- package/distribution/runtime/src/source/code-language.ts +277 -0
- package/distribution/runtime/src/source/component-insertion.ts +160 -0
- package/distribution/runtime/src/source/deletion.ts +278 -0
- package/distribution/runtime/src/source/graphs.ts +517 -0
- package/distribution/runtime/src/source/identities.ts +256 -0
- package/distribution/runtime/src/source/identity-id.ts +6 -0
- package/distribution/runtime/src/source/image.ts +50 -0
- package/distribution/runtime/src/source/kpi.ts +196 -0
- package/distribution/runtime/src/source/mod.ts +876 -0
- package/distribution/runtime/src/source/notes.ts +308 -0
- package/distribution/runtime/src/source/structure.ts +647 -0
- package/distribution/runtime/src/source/table.ts +290 -0
- package/distribution/runtime/src/source/text-conversion.ts +146 -0
- package/distribution/runtime/src/state/components.ts +281 -0
- package/distribution/runtime/src/state/graphs.ts +200 -0
- package/distribution/runtime/src/state/json.ts +162 -0
- package/distribution/runtime/src/state/merge.ts +1034 -0
- package/distribution/runtime/src/state/mod.ts +5 -0
- package/distribution/runtime/src/state/store.ts +226 -0
- package/distribution/runtime/src/state/types.ts +151 -0
- package/distribution/runtime/src/studio/client.js +790 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +278 -0
- package/distribution/runtime/src/studio/layouts.js +170 -0
- package/distribution/runtime/src/studio/registry.js +283 -0
- package/distribution/runtime/src/studio/relations.js +169 -0
- package/distribution/runtime/src/studio/server.ts +433 -0
- package/distribution/runtime/src/widgets/deck.ts +39 -0
- package/distribution/runtime/src/widgets/plotly.ts +354 -0
- package/distribution/runtime/src/widgets/presenter.ts +367 -0
- package/distribution/runtime/src/widgets/snapshot.ts +116 -0
- package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
- package/distribution/runtime/vendor/bento/LICENSE +21 -0
- package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
- package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/pin.json +69 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
- package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
- package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
- package/distribution/runtime/vendor/bento/shell.html +269 -0
- package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
- package/package.json +40 -4
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Offline shell with readable notices retained by native Bento self-save. */
|
|
2
|
+
export const SHELL_NOTICES_ID = "qbento-third-party-notices";
|
|
3
|
+
export interface ShellNotices {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
scope:
|
|
6
|
+
"project-license-and-pinned-bento-fonts-and-production-archive-notices";
|
|
7
|
+
files: {
|
|
8
|
+
path: string;
|
|
9
|
+
component?: "Quarto-Bento";
|
|
10
|
+
sha256: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The pinned Bento runtime puts a chart colour into a quoted tooltip style
|
|
16
|
+
// attribute before assigning innerHTML. A native dropped document reaches that
|
|
17
|
+
// renderer before Quarto-Bento can validate it, so guard the sink in the shell
|
|
18
|
+
// bootstrap as well as at project ingress. Keep the upstream bytes pinned and
|
|
19
|
+
// fail closed if the exact reviewed runtime changes.
|
|
20
|
+
const tooltipColorSink = "background:${A.color};margin-right:5px";
|
|
21
|
+
const escapedTooltipColorSink =
|
|
22
|
+
'background:${String(A.color).replaceAll("&","&").replaceAll(\'"\',""").replaceAll("<","<").replaceAll(">",">")};margin-right:5px';
|
|
23
|
+
const runtimeBootstrap = " var js = inflate('bento-rt')";
|
|
24
|
+
function guardNativeTooltip(shell: string): string {
|
|
25
|
+
if (shell.split(runtimeBootstrap).length !== 2) {
|
|
26
|
+
throw new Error("BENTO_TOOLTIP_PIN_MISMATCH: runtime bootstrap changed");
|
|
27
|
+
}
|
|
28
|
+
const patch = ` var js = inflate('bento-rt')
|
|
29
|
+
var qbentoTooltipSink = ${JSON.stringify(tooltipColorSink)}
|
|
30
|
+
if (js.split(qbentoTooltipSink).length !== 2) throw new Error('BENTO_TOOLTIP_PIN_MISMATCH: tooltip changed')
|
|
31
|
+
js = js.replace(qbentoTooltipSink, ${
|
|
32
|
+
JSON.stringify(escapedTooltipColorSink)
|
|
33
|
+
})`;
|
|
34
|
+
return shell.replace(runtimeBootstrap, patch);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function sha256(text: string): Promise<string> {
|
|
38
|
+
return Array.from(
|
|
39
|
+
new Uint8Array(
|
|
40
|
+
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)),
|
|
41
|
+
),
|
|
42
|
+
(n) => n.toString(16).padStart(2, "0"),
|
|
43
|
+
).join("");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Keep the vendored shell unchanged; guard its pinned tooltip and add notices
|
|
47
|
+
* before its pristine capture.
|
|
48
|
+
* The inert data block is outside BentoDoc, never a runtime-owned transient.
|
|
49
|
+
* The pinned kernel therefore retains it through native serialization/save.
|
|
50
|
+
* The project's MIT license is labeled separately from pinned upstream texts.
|
|
51
|
+
* This carriage does not certify complete third-party coverage.
|
|
52
|
+
*/
|
|
53
|
+
export async function loadBentoShell(): Promise<string> {
|
|
54
|
+
const vendor = new URL("../../vendor/bento/", import.meta.url);
|
|
55
|
+
const pin = JSON.parse(
|
|
56
|
+
await Deno.readTextFile(new URL("pin.json", vendor)),
|
|
57
|
+
);
|
|
58
|
+
const readPinned = async (path: string, expected: unknown) => {
|
|
59
|
+
const text = await Deno.readTextFile(new URL(path, vendor));
|
|
60
|
+
const digest = await sha256(text);
|
|
61
|
+
if (typeof expected !== "string" || digest !== expected) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`BENTO_NOTICE_INTEGRITY: pinned bytes differ for ${path}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return { path, sha256: digest, text };
|
|
67
|
+
};
|
|
68
|
+
const [shell, ...files] = await Promise.all([
|
|
69
|
+
readPinned("shell.html", pin.shellSha256),
|
|
70
|
+
readPinned("LICENSE", pin.upstreamArtifacts?.license?.sha256),
|
|
71
|
+
readPinned(
|
|
72
|
+
"THIRD_PARTY_NOTICES.md",
|
|
73
|
+
pin.upstreamArtifacts?.thirdPartyNotices?.sha256,
|
|
74
|
+
),
|
|
75
|
+
readPinned("FONT_NOTICES.md", pin.fontNotices?.sha256),
|
|
76
|
+
readPinned("runtime-licenses.json", pin.runtimeLicenseInventory?.sha256),
|
|
77
|
+
readPinned(
|
|
78
|
+
"font-licenses/fraunces-OFL.txt",
|
|
79
|
+
pin.fontLicenseArtifacts?.fraunces?.sha256,
|
|
80
|
+
),
|
|
81
|
+
readPinned(
|
|
82
|
+
"font-licenses/instrument-sans-OFL.txt",
|
|
83
|
+
pin.fontLicenseArtifacts?.instrumentSans?.sha256,
|
|
84
|
+
),
|
|
85
|
+
]);
|
|
86
|
+
// This resource has the same relative path in the checkout, portable runtime
|
|
87
|
+
// and installed extension. Read lazily so importing the loader performs no I/O.
|
|
88
|
+
const projectLicense = await Deno.readTextFile(
|
|
89
|
+
new URL("../../LICENSE", import.meta.url),
|
|
90
|
+
);
|
|
91
|
+
const notices: ShellNotices = {
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
scope:
|
|
94
|
+
"project-license-and-pinned-bento-fonts-and-production-archive-notices",
|
|
95
|
+
files: [...files, {
|
|
96
|
+
path: "Quarto-Bento/LICENSE",
|
|
97
|
+
component: "Quarto-Bento",
|
|
98
|
+
sha256: await sha256(projectLicense),
|
|
99
|
+
text: projectLicense,
|
|
100
|
+
}],
|
|
101
|
+
};
|
|
102
|
+
// Escaping '<' also makes future supplied text containing script-close tokens
|
|
103
|
+
// safe. JSON decoding recovers the full original text, including newlines.
|
|
104
|
+
const body = JSON.stringify(notices).replaceAll("<", "\\u003c");
|
|
105
|
+
return guardNativeTooltip(shell.text).replace(
|
|
106
|
+
/<head([^>]*)>/i,
|
|
107
|
+
(_match, attributes) =>
|
|
108
|
+
`<head${attributes}><script id="${SHELL_NOTICES_ID}" type="application/json">${body}</script>`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authoredImageData,
|
|
3
|
+
type AuthoredImageShape,
|
|
4
|
+
originalImageShape,
|
|
5
|
+
} from "./images.ts";
|
|
6
|
+
/** Read authored fragments with Pandoc only; never execute Quarto or infer output. */
|
|
7
|
+
import type { SourceDocument } from "../source/mod.ts";
|
|
8
|
+
import { mathToNativeHtml } from "./math.ts";
|
|
9
|
+
import { renderList } from "./lists.ts";
|
|
10
|
+
import { renderInline } from "./inline.ts";
|
|
11
|
+
import type { Node } from "./types.ts";
|
|
12
|
+
export interface SourceInlineProof {
|
|
13
|
+
sourceHash: string;
|
|
14
|
+
blockId: string;
|
|
15
|
+
kind: "heading" | "paragraph" | "list" | "math" | "code" | "image";
|
|
16
|
+
html: string;
|
|
17
|
+
image?: AuthoredImageShape & { data?: string };
|
|
18
|
+
code?: { content: string; grammarName: string | null };
|
|
19
|
+
readerId?: string;
|
|
20
|
+
}
|
|
21
|
+
export async function readSourceInline(
|
|
22
|
+
source: SourceDocument,
|
|
23
|
+
imageRoot?: string,
|
|
24
|
+
): Promise<ReadonlyMap<string, SourceInlineProof>> {
|
|
25
|
+
const fragments = source.blocks.filter((block) =>
|
|
26
|
+
block.origin === "authored" && block.editable &&
|
|
27
|
+
["heading", "paragraph", "list", "math", "code", "image"].includes(
|
|
28
|
+
block.kind,
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
const proofs = new Map<string, SourceInlineProof>();
|
|
32
|
+
if (!fragments.length) return proofs;
|
|
33
|
+
// Every fragment has an independent boundary. Heading order is retained so
|
|
34
|
+
// Pandoc's duplicate automatic identifiers match the original document.
|
|
35
|
+
const input = fragments.map((block, index) =>
|
|
36
|
+
`::: {#qbento-inline-proof-${index}}\n\n${block.raw}\n\n:::\n`
|
|
37
|
+
).join("\n");
|
|
38
|
+
const process = new Deno.Command("quarto", {
|
|
39
|
+
args: ["pandoc", "--from", "markdown", "--to", "json"],
|
|
40
|
+
stdin: "piped",
|
|
41
|
+
stdout: "piped",
|
|
42
|
+
stderr: "piped",
|
|
43
|
+
}).spawn();
|
|
44
|
+
const output = process.output(), writer = process.stdin.getWriter();
|
|
45
|
+
await writer.write(new TextEncoder().encode(input));
|
|
46
|
+
await writer.close();
|
|
47
|
+
const result = await output;
|
|
48
|
+
if (!result.success) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"UNMAPPED_PROVENANCE: Pandoc could not read original authored fragments",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const parsed = JSON.parse(new TextDecoder().decode(result.stdout));
|
|
54
|
+
if (
|
|
55
|
+
!Array.isArray(parsed.blocks) || parsed.blocks.length !== fragments.length
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"UNMAPPED_PROVENANCE: original fragment reader boundaries differ",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
for (const [index, block] of fragments.entries()) {
|
|
62
|
+
const wrapper = parsed.blocks[index] as Node;
|
|
63
|
+
const [attr, children] = wrapper.c as [
|
|
64
|
+
[string, string[], unknown[]],
|
|
65
|
+
Node[],
|
|
66
|
+
];
|
|
67
|
+
if (
|
|
68
|
+
wrapper.t !== "Div" || attr[0] !== `qbento-inline-proof-${index}` ||
|
|
69
|
+
children.length !== 1
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"UNMAPPED_PROVENANCE: original fragment is not one faithful inline block",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const node = children[0];
|
|
76
|
+
if (block.kind === "image") {
|
|
77
|
+
const image = originalImageShape(node);
|
|
78
|
+
const data = imageRoot
|
|
79
|
+
? await authoredImageData(image, imageRoot)
|
|
80
|
+
: undefined;
|
|
81
|
+
proofs.set(block.id, {
|
|
82
|
+
sourceHash: source.hash,
|
|
83
|
+
blockId: block.id,
|
|
84
|
+
kind: "image",
|
|
85
|
+
html: image.captionHtml,
|
|
86
|
+
image: { ...image, ...(data ? { data } : {}) },
|
|
87
|
+
});
|
|
88
|
+
} else if (block.kind === "heading" && node.t === "Header") {
|
|
89
|
+
const [level, headerAttr, nodes] = node.c as [
|
|
90
|
+
number,
|
|
91
|
+
[string, string[], unknown[]],
|
|
92
|
+
Node[],
|
|
93
|
+
];
|
|
94
|
+
if (level !== block.level) {
|
|
95
|
+
throw new Error("UNMAPPED_PROVENANCE: original heading level differs");
|
|
96
|
+
}
|
|
97
|
+
proofs.set(block.id, {
|
|
98
|
+
sourceHash: source.hash,
|
|
99
|
+
blockId: block.id,
|
|
100
|
+
kind: "heading",
|
|
101
|
+
html: renderInline(nodes),
|
|
102
|
+
readerId: headerAttr[0],
|
|
103
|
+
});
|
|
104
|
+
} else if (block.kind === "code" && node.t === "CodeBlock") {
|
|
105
|
+
const [attributes, content] = node.c as [
|
|
106
|
+
[string, string[], unknown[]],
|
|
107
|
+
string,
|
|
108
|
+
];
|
|
109
|
+
if (
|
|
110
|
+
attributes[1].length > 1 ||
|
|
111
|
+
content !== block.content.replace(/\r\n/g, "\n").replace(/\n$/, "")
|
|
112
|
+
) {
|
|
113
|
+
throw new Error("UNMAPPED_PROVENANCE: original displayed code differs");
|
|
114
|
+
}
|
|
115
|
+
proofs.set(block.id, {
|
|
116
|
+
sourceHash: source.hash,
|
|
117
|
+
blockId: block.id,
|
|
118
|
+
kind: "code",
|
|
119
|
+
html: "",
|
|
120
|
+
code: { content, grammarName: attributes[1][0] ?? null },
|
|
121
|
+
});
|
|
122
|
+
} else if (block.kind === "math" && node.t === "Para") {
|
|
123
|
+
const nodes = node.c as Node[];
|
|
124
|
+
const formula = nodes.length === 1 && nodes[0].t === "Math"
|
|
125
|
+
? nodes[0].c as [{ t: string }, string]
|
|
126
|
+
: undefined;
|
|
127
|
+
if (
|
|
128
|
+
!formula || formula[0].t !== "DisplayMath" ||
|
|
129
|
+
mathToNativeHtml(formula[1]) !== mathToNativeHtml(block.content)
|
|
130
|
+
) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"UNMAPPED_PROVENANCE: original display equation differs",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
proofs.set(block.id, {
|
|
136
|
+
sourceHash: source.hash,
|
|
137
|
+
blockId: block.id,
|
|
138
|
+
kind: "math",
|
|
139
|
+
html: mathToNativeHtml(formula[1]),
|
|
140
|
+
});
|
|
141
|
+
} else if (block.kind === "paragraph" && node.t === "Para") {
|
|
142
|
+
proofs.set(block.id, {
|
|
143
|
+
sourceHash: source.hash,
|
|
144
|
+
blockId: block.id,
|
|
145
|
+
kind: "paragraph",
|
|
146
|
+
html: renderInline(node.c as Node[]),
|
|
147
|
+
});
|
|
148
|
+
} else if (
|
|
149
|
+
block.kind === "list" && ["BulletList", "OrderedList"].includes(node.t)
|
|
150
|
+
) {
|
|
151
|
+
if (/^\s*(?:[-+*]|[0-9]+[.)])\s+\[[ xX]\](?:\s|$)/m.test(block.content)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"UNSUPPORTED_LIST: task-list checkbox semantics need a dedicated native adapter",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
proofs.set(block.id, {
|
|
157
|
+
sourceHash: source.hash,
|
|
158
|
+
blockId: block.id,
|
|
159
|
+
kind: "list",
|
|
160
|
+
html: renderList(node),
|
|
161
|
+
});
|
|
162
|
+
} else {
|
|
163
|
+
throw new Error(
|
|
164
|
+
"UNMAPPED_PROVENANCE: original fragment is not faithful supported text",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return proofs;
|
|
169
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/** Faithful rectangular plain-text subset of Pandoc tables -> pinned native Bento tables. */
|
|
2
|
+
import type { Frame } from "./design.ts";
|
|
3
|
+
import { escapeHtml } from "./html.ts";
|
|
4
|
+
import type { Element, Node } from "./types.ts";
|
|
5
|
+
export const TABLE_CODEC_VERSION = 1;
|
|
6
|
+
export type TableAlign = "left" | "center" | "right";
|
|
7
|
+
export interface PlainTable {
|
|
8
|
+
header: boolean;
|
|
9
|
+
columns: { width: number; align: TableAlign }[];
|
|
10
|
+
rows: { text: string; align: TableAlign }[][];
|
|
11
|
+
}
|
|
12
|
+
function fail(message: string): never {
|
|
13
|
+
throw new Error(`UNSUPPORTED_TABLE: ${message}`);
|
|
14
|
+
}
|
|
15
|
+
function array(value: unknown, length?: number): unknown[] {
|
|
16
|
+
if (
|
|
17
|
+
!Array.isArray(value) || length !== undefined && value.length !== length
|
|
18
|
+
) {
|
|
19
|
+
fail("Malformed table structure.");
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function attr(value: unknown, allowed: string[] = []) {
|
|
24
|
+
const [id, classes, pairs] = array(value, 3);
|
|
25
|
+
if (
|
|
26
|
+
id !== "" ||
|
|
27
|
+
array(classes).some((c) => typeof c !== "string" || !allowed.includes(c)) ||
|
|
28
|
+
array(pairs).length
|
|
29
|
+
) {
|
|
30
|
+
fail("Cell, row and table attributes need an explicit supported mapping.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function alignment(value: unknown, fallback: TableAlign = "left"): TableAlign {
|
|
34
|
+
const name = (value as Node)?.t;
|
|
35
|
+
if (name === "AlignDefault") return fallback;
|
|
36
|
+
if (name === "AlignLeft") return "left";
|
|
37
|
+
if (name === "AlignCenter") return "center";
|
|
38
|
+
if (name === "AlignRight") return "right";
|
|
39
|
+
return fail("Unknown column/cell alignment.");
|
|
40
|
+
}
|
|
41
|
+
function cellText(value: unknown): string {
|
|
42
|
+
const blocks = array(value);
|
|
43
|
+
if (!blocks.length) return "";
|
|
44
|
+
if (blocks.length !== 1) {
|
|
45
|
+
fail("Multiple paragraphs in a cell are not supported.");
|
|
46
|
+
}
|
|
47
|
+
const block = blocks[0] as Node;
|
|
48
|
+
if (!block || !["Plain", "Para"].includes(block.t)) {
|
|
49
|
+
fail("Cells must contain one plain-text paragraph.");
|
|
50
|
+
}
|
|
51
|
+
return array(block.c).map((value) => {
|
|
52
|
+
const node = value as Node;
|
|
53
|
+
if (node.t === "Str" && typeof node.c === "string") return node.c;
|
|
54
|
+
if (node.t === "Space") return " ";
|
|
55
|
+
return fail(
|
|
56
|
+
`Cell content ${node.t} needs a faithful rich-content adapter; no content was flattened.`,
|
|
57
|
+
);
|
|
58
|
+
}).join("");
|
|
59
|
+
}
|
|
60
|
+
export function plainTable(node: Node): PlainTable {
|
|
61
|
+
if (node.t !== "Table") fail("Expected a Pandoc Table node.");
|
|
62
|
+
const [attributes, caption, specs, head, bodies, foot] = array(node.c, 6);
|
|
63
|
+
attr(attributes, ["caption-top"]);
|
|
64
|
+
const [shortCaption, longCaption] = array(caption, 2);
|
|
65
|
+
if (shortCaption !== null || array(longCaption).length) {
|
|
66
|
+
fail("Captions need a supported semantic/visual mapping.");
|
|
67
|
+
}
|
|
68
|
+
const columns = array(specs).map((value) => {
|
|
69
|
+
const [align, widthValue] = array(value, 2), width = widthValue as Node;
|
|
70
|
+
if (!width || !["ColWidthDefault", "ColWidth"].includes(width.t)) {
|
|
71
|
+
fail("Unknown column width.");
|
|
72
|
+
}
|
|
73
|
+
const w = width.t === "ColWidthDefault" ? 1 : width.c;
|
|
74
|
+
if (typeof w !== "number" || !Number.isFinite(w) || w <= 0) {
|
|
75
|
+
fail("Column widths must be finite positive weights.");
|
|
76
|
+
}
|
|
77
|
+
return { width: w, align: alignment(align) };
|
|
78
|
+
});
|
|
79
|
+
if (!columns.length || columns.length > 200) {
|
|
80
|
+
fail("Use between 1 and 200 columns.");
|
|
81
|
+
}
|
|
82
|
+
const widthKinds = new Set(
|
|
83
|
+
array(specs).map((value) => (array(value, 2)[1] as Node).t),
|
|
84
|
+
);
|
|
85
|
+
if (widthKinds.size > 1) {
|
|
86
|
+
fail(
|
|
87
|
+
"Mixed automatic and explicit widths need an explicit layout decision.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const [headAttr, headRows] = array(head, 2);
|
|
91
|
+
const [footAttr, footRows] = array(foot, 2);
|
|
92
|
+
attr(headAttr);
|
|
93
|
+
attr(footAttr);
|
|
94
|
+
if (array(headRows).length > 1 || array(footRows).length) {
|
|
95
|
+
fail("Multiple header rows and table footers are not supported.");
|
|
96
|
+
}
|
|
97
|
+
if (array(bodies).length !== 1) {
|
|
98
|
+
fail("Multiple table bodies are not supported.");
|
|
99
|
+
}
|
|
100
|
+
const [bodyAttr, rowHeads, intermediateHeads, bodyRows] = array(
|
|
101
|
+
array(bodies)[0],
|
|
102
|
+
4,
|
|
103
|
+
);
|
|
104
|
+
attr(bodyAttr);
|
|
105
|
+
if (rowHeads !== 0 || array(intermediateHeads).length) {
|
|
106
|
+
fail("Row headers and intermediate header sections are not supported.");
|
|
107
|
+
}
|
|
108
|
+
const rows = [...array(headRows), ...array(bodyRows)].map((value) => {
|
|
109
|
+
const [rowAttr, cells] = array(value, 2);
|
|
110
|
+
attr(rowAttr, ["header", "odd", "even"]);
|
|
111
|
+
if (array(cells).length !== columns.length) {
|
|
112
|
+
fail("Rows must be rectangular with one cell per column.");
|
|
113
|
+
}
|
|
114
|
+
return array(cells).map((value, index) => {
|
|
115
|
+
const [cellAttr, align, rowSpan, colSpan, content] = array(value, 5);
|
|
116
|
+
attr(cellAttr);
|
|
117
|
+
if (rowSpan !== 1 || colSpan !== 1) {
|
|
118
|
+
fail("Merged cells and row/column spans are not supported.");
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
text: cellText(content),
|
|
122
|
+
align: alignment(align, columns[index].align),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
if (!rows.length || rows.length > 2000) fail("Use between 1 and 2000 rows.");
|
|
127
|
+
return { header: array(headRows).length === 1, columns, rows };
|
|
128
|
+
}
|
|
129
|
+
export function projectTable(
|
|
130
|
+
node: Node,
|
|
131
|
+
id: string,
|
|
132
|
+
y = 166,
|
|
133
|
+
frame?: Frame,
|
|
134
|
+
): Element {
|
|
135
|
+
const table = plainTable(node);
|
|
136
|
+
const fontSize = 18, cellPadX = 16, cellPadY = 11, borderWidth = 1;
|
|
137
|
+
const width = frame?.w ?? 1120;
|
|
138
|
+
const height = tableHeight(
|
|
139
|
+
table,
|
|
140
|
+
frame ?? { x: 80, y, w: width, h: 720 - y },
|
|
141
|
+
y,
|
|
142
|
+
);
|
|
143
|
+
if (y + height > 720) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"UNSUPPORTED_TABLE_LAYOUT: the complete table exceeds the projected slide area.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
id,
|
|
150
|
+
type: "table",
|
|
151
|
+
x: frame?.x ?? 80,
|
|
152
|
+
y,
|
|
153
|
+
w: width,
|
|
154
|
+
h: height,
|
|
155
|
+
rotation: 0,
|
|
156
|
+
opacity: 1,
|
|
157
|
+
header: table.header,
|
|
158
|
+
columns: table.columns.map((column) => ({ w: column.width })),
|
|
159
|
+
rows: table.rows.map((row) => ({
|
|
160
|
+
cells: row.map((cell) => ({
|
|
161
|
+
html: escapeHtml(cell.text),
|
|
162
|
+
align: cell.align,
|
|
163
|
+
})),
|
|
164
|
+
})),
|
|
165
|
+
style: {
|
|
166
|
+
headerBg: "#1E2A3A",
|
|
167
|
+
headerColor: "#FFFFFF",
|
|
168
|
+
zebra: "rgba(30,42,58,0.05)",
|
|
169
|
+
borderColor: "rgba(30,42,58,0.14)",
|
|
170
|
+
borderWidth,
|
|
171
|
+
cellPadX,
|
|
172
|
+
cellPadY,
|
|
173
|
+
fontSize,
|
|
174
|
+
color: "#1E2A3A",
|
|
175
|
+
radius: 10,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Shared conservative metrics for projection and explicit reusable-layout placement. */
|
|
181
|
+
export function tableHeight(
|
|
182
|
+
table: PlainTable,
|
|
183
|
+
frame: Frame,
|
|
184
|
+
y = frame.y,
|
|
185
|
+
): number {
|
|
186
|
+
const fontSize = 18, cellPadX = 16, cellPadY = 11, borderWidth = 1;
|
|
187
|
+
// Match native line-height/padding/border before adding a small rounding margin.
|
|
188
|
+
// A conservative single-line width gate avoids claiming unmeasured wrapping.
|
|
189
|
+
const width = frame.w,
|
|
190
|
+
totalWeight = table.columns.reduce((sum, column) => sum + column.width, 0);
|
|
191
|
+
for (const [rowIndex, row] of table.rows.entries()) {
|
|
192
|
+
for (const [columnIndex, cell] of row.entries()) {
|
|
193
|
+
const usableWidth =
|
|
194
|
+
width * table.columns[columnIndex].width / totalWeight - cellPadX * 2 -
|
|
195
|
+
borderWidth * 2;
|
|
196
|
+
if (
|
|
197
|
+
usableWidth <= 0 ||
|
|
198
|
+
Array.from(cell.text).length * fontSize * 2 > usableWidth
|
|
199
|
+
) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`UNSUPPORTED_TABLE_LAYOUT: row ${rowIndex + 1}, column ${
|
|
202
|
+
columnIndex + 1
|
|
203
|
+
} exceeds the conservative single-line width limit; shorten or split the source table. Wrapped table-cell layout needs separate measured support.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const rowHeight = Math.ceil(fontSize * 1.3 + cellPadY * 2 + borderWidth * 2) +
|
|
209
|
+
8;
|
|
210
|
+
const height = Math.max(100, table.rows.length * rowHeight);
|
|
211
|
+
if (
|
|
212
|
+
!Number.isFinite(width) || width <= 0 || y < 0 ||
|
|
213
|
+
(y < frame.y || y + height > frame.y + frame.h + .01)
|
|
214
|
+
) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
"UNSUPPORTED_TABLE_LAYOUT: the complete table does not fit in the remaining slide area; split it into smaller source tables rather than clipping rows.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return height;
|
|
220
|
+
}
|