@entropicwarrior/sdoc 0.2.14 → 0.2.16
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/docs/reference/sdoc-authoring.sdoc +3 -0
- package/docs/reference/slide-authoring.sdoc +89 -5
- package/lexica/specification.sdoc +3 -0
- package/package.json +1 -1
- package/src/sdoc.js +60 -1
- package/src/slide-renderer.js +110 -9
- package/tools/build-doc.js +37 -1
|
@@ -384,8 +384,11 @@ Content of Section B.
|
|
|
384
384
|
\`\{!text!\}\` | Warning marker (orange)
|
|
385
385
|
\`\{-text-\}\` | Negative marker (red)
|
|
386
386
|
\`\{~text~\}\` | Highlight (yellow)
|
|
387
|
+
\`#1a73e8\` | Hex color swatch (auto-readable text)
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
Hex color codes (\`#rgb\`, \`#rgba\`, \`#rrggbb\`, \`#rrggbbaa\`) render as inline swatches — a rounded box filled with the color and labelled with the code, with the text color chosen automatically (black or white) for legibility. They are detected in normal text, list items, table cells, and headings, but not inside \`\\\`inline code\\\`\` (which stays literal). A bare \`#\` at the start of a line is a heading; escape with \`\\#\` to keep a literal hash.
|
|
391
|
+
|
|
389
392
|
Links: \`[Link text](https://example.com)\` or \`[Other doc](./other-file.sdoc)\`. Relative paths resolve from the document's directory.
|
|
390
393
|
|
|
391
394
|
Images: \`\`
|
|
@@ -239,6 +239,84 @@
|
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
# Drilldown Slides @drilldown
|
|
243
|
+
{
|
|
244
|
+
A slide deck is normally a 1D spine: Right and Left move along it. A
|
|
245
|
+
spine slide can also have *vertical detail slides* — extra material
|
|
246
|
+
you can pull up on demand without cluttering the main flow. Mark a
|
|
247
|
+
child scope with the `:detail` annotation and it becomes a vertical
|
|
248
|
+
slide under its parent.
|
|
249
|
+
|
|
250
|
+
# Syntax @drilldown-syntax
|
|
251
|
+
{
|
|
252
|
+
```
|
|
253
|
+
# ETHyR Mechanism @ethyr {
|
|
254
|
+
Main spine content.
|
|
255
|
+
|
|
256
|
+
# PCR primer @pcr :detail {
|
|
257
|
+
Detail slide one.
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# HCR primer @hcr :detail {
|
|
261
|
+
Detail slide two.
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# Notes @notes {
|
|
265
|
+
Speaker notes still work alongside details.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
A detail slide is a regular slide. It can have its own `config:
|
|
271
|
+
center`, two-column layout, speaker `@notes`, and any other slide
|
|
272
|
+
content. Drilldowns do not nest further: a `:detail` inside a
|
|
273
|
+
`:detail` is treated as a plain nested scope.
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
# Navigation @drilldown-navigation
|
|
277
|
+
{
|
|
278
|
+
Right — advance one step. From a spine slide, move to the next
|
|
279
|
+
spine. From a detail slide, advance to the next detail in the
|
|
280
|
+
same column; at the last detail, return to the parent spine and
|
|
281
|
+
advance to the next spine (reveal.js convention).
|
|
282
|
+
|
|
283
|
+
Left — mirror of Right. From a detail, previous detail or back
|
|
284
|
+
to parent spine; from spine, previous spine.
|
|
285
|
+
|
|
286
|
+
Down — drill into the first detail of the current spine; from
|
|
287
|
+
within a column, advance to the next detail.
|
|
288
|
+
|
|
289
|
+
Up — return from a detail to its parent spine. No-op on a spine.
|
|
290
|
+
|
|
291
|
+
Swipe — horizontal swipes mirror Left / Right; vertical swipes
|
|
292
|
+
mirror Up / Down.
|
|
293
|
+
|
|
294
|
+
URL hash — spine slides use `#N`; detail K of spine N uses
|
|
295
|
+
`#N.K`.
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# Visual Affordances @drilldown-visual
|
|
299
|
+
{
|
|
300
|
+
Spine slides that have detail children get the
|
|
301
|
+
`slide-has-details` class — the default theme shows a small
|
|
302
|
+
animated chevron at the bottom of the slide. Themes can replace
|
|
303
|
+
this rule to fit their own visual language.
|
|
304
|
+
|
|
305
|
+
Every slide also gets a `.slide-indicator` element in the
|
|
306
|
+
bottom-right corner: `5 / 14` on a spine, `5.2 / 14` on detail 2
|
|
307
|
+
of slide 5. The denominator is always the spine count, so the
|
|
308
|
+
indicator stays stable as you drill in.
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# PDF Export @drilldown-pdf
|
|
312
|
+
{
|
|
313
|
+
PDF export flattens the deck: spine 1, its details in order,
|
|
314
|
+
spine 2, its details in order, and so on. This is the same order
|
|
315
|
+
slides appear in the HTML, so PDF export needs no special case —
|
|
316
|
+
every slide is just one page.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
242
320
|
# Theme System @themes
|
|
243
321
|
{
|
|
244
322
|
Themes control the visual appearance of slides. A theme is a directory
|
|
@@ -269,17 +347,23 @@
|
|
|
269
347
|
{
|
|
270
348
|
All themes include these controls by default:
|
|
271
349
|
|
|
272
|
-
Arrow right or Space — next slide.
|
|
350
|
+
Arrow right or Space — next slide (or next detail).
|
|
351
|
+
|
|
352
|
+
Arrow left — previous slide (or previous detail).
|
|
353
|
+
|
|
354
|
+
Arrow down — drill into details (if any).
|
|
273
355
|
|
|
274
|
-
Arrow
|
|
356
|
+
Arrow up — return from details to the spine slide.
|
|
275
357
|
|
|
276
358
|
Home — first slide.
|
|
277
359
|
|
|
278
|
-
End — last slide.
|
|
360
|
+
End — last spine slide.
|
|
279
361
|
|
|
280
|
-
Touch swipe
|
|
362
|
+
Touch swipe horizontal / vertical on mobile devices.
|
|
281
363
|
|
|
282
|
-
Slide counter shown in the bottom-right
|
|
364
|
+
Slide counter (`.slide-indicator`) shown in the bottom-right
|
|
365
|
+
corner — `N / TOTAL` on spine slides, `N.K / TOTAL` on detail K
|
|
366
|
+
of spine N. See [Drilldown Slides](#drilldown) for details.
|
|
283
367
|
}
|
|
284
368
|
}
|
|
285
369
|
|
|
@@ -719,12 +719,15 @@ Content of Section B.
|
|
|
719
719
|
- Warning marker: `{!text!}` (orange highlight)
|
|
720
720
|
- Negative marker: `{-text-}` (red highlight)
|
|
721
721
|
- Highlight: `{~text~}` (yellow highlight)
|
|
722
|
+
- Hex color swatch: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` (e.g. `#9aa0a8`) renders as a filled inline box labelled with the code
|
|
722
723
|
- Citation reference: `[@key]` (numbered superscript link to citation entry)
|
|
723
724
|
- Multiple citation references: `[@key1, @key2]` (each individually linked)
|
|
724
725
|
}
|
|
725
726
|
|
|
726
727
|
Inline math requires non-whitespace immediately after the opening `$` and before the closing `$`. A plain `$` followed by a digit (e.g. `$100`) does not trigger math mode because there is no closing `$`.
|
|
727
728
|
|
|
729
|
+
Hex color swatches are detected only when the `#` is at the start of the inline text or preceded by a non-alphanumeric character, the digit run is exactly 3, 4, 6, or 8 hexadecimal digits, and it is not immediately followed by another letter, digit, or underscore. The background is the literal color and the text color (black or white) is chosen by perceptual brightness (YIQ); for `#rgba`/`#rrggbbaa` the alpha channel is ignored when choosing the text color. Hex inside `` `inline code` `` stays literal, and a `#` at the start of a line is parsed as a heading (escape with `\#`).
|
|
730
|
+
|
|
728
731
|
Citation references (`[@key]`) are parsed before link syntax. `[@key]` is always a citation reference even if followed by `(url)`. Use `\[@key]` for a literal `[@key]` in text.
|
|
729
732
|
}
|
|
730
733
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@entropicwarrior/sdoc",
|
|
3
3
|
"displayName": "SDOC - Docs for Human/Agent Teams",
|
|
4
4
|
"description": "A plain-text documentation format with explicit brace scoping — deterministic parsing, AI-agent efficiency, and 10-50x token savings vs Markdown.",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.16",
|
|
6
6
|
"publisher": "entropicwarrior-msenfin",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
package/src/sdoc.js
CHANGED
|
@@ -1414,6 +1414,21 @@ function parseInline(text) {
|
|
|
1414
1414
|
}
|
|
1415
1415
|
}
|
|
1416
1416
|
|
|
1417
|
+
// Hex color codes (#rgb, #rgba, #rrggbb, #rrggbbaa) render as swatches.
|
|
1418
|
+
// Require a non-word char before the # so we don't match inside identifiers,
|
|
1419
|
+
// and a non-word char after the digits so partial/over-long runs are ignored.
|
|
1420
|
+
if (ch === "#" && (i === 0 || !/[0-9A-Za-z]/.test(text[i - 1]))) {
|
|
1421
|
+
const m = /^#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9A-Za-z_])/.exec(
|
|
1422
|
+
text.slice(i)
|
|
1423
|
+
);
|
|
1424
|
+
if (m) {
|
|
1425
|
+
flush();
|
|
1426
|
+
nodes.push({ type: "color_swatch", value: m[0] });
|
|
1427
|
+
i += m[0].length;
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1417
1432
|
if (ch === "{") {
|
|
1418
1433
|
const mc = next;
|
|
1419
1434
|
let mt = null;
|
|
@@ -1728,6 +1743,46 @@ function escapeAttr(value) {
|
|
|
1728
1743
|
return escapeHtml(value).replace(/'/g, "'");
|
|
1729
1744
|
}
|
|
1730
1745
|
|
|
1746
|
+
// Expand a #rgb/#rgba/#rrggbb/#rrggbbaa hex string to [r, g, b] (alpha ignored).
|
|
1747
|
+
function hexToRgb(hex) {
|
|
1748
|
+
let h = hex.replace(/^#/, "");
|
|
1749
|
+
if (h.length === 3 || h.length === 4) {
|
|
1750
|
+
h = h.slice(0, 3).split("").map((c) => c + c).join("");
|
|
1751
|
+
} else {
|
|
1752
|
+
h = h.slice(0, 6);
|
|
1753
|
+
}
|
|
1754
|
+
return [
|
|
1755
|
+
parseInt(h.slice(0, 2), 16),
|
|
1756
|
+
parseInt(h.slice(2, 4), 16),
|
|
1757
|
+
parseInt(h.slice(4, 6), 16),
|
|
1758
|
+
];
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// Pick black or white text for legibility over the given background color
|
|
1762
|
+
// using the perceptual YIQ brightness threshold.
|
|
1763
|
+
function readableTextColor(hex) {
|
|
1764
|
+
const [r, g, b] = hexToRgb(hex);
|
|
1765
|
+
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
|
1766
|
+
return yiq >= 128 ? "#000000" : "#ffffff";
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Render a hex color as a self-contained inline swatch: a rounded box filled
|
|
1770
|
+
// with the color, labelled with the hex code in a legible text color. Styling
|
|
1771
|
+
// is inline so it survives in slides, exports, and other CSS-free contexts.
|
|
1772
|
+
function colorSwatchHtml(value) {
|
|
1773
|
+
const fg = readableTextColor(value);
|
|
1774
|
+
// A soft neutral-grey outline on every swatch (the same for all of them) so
|
|
1775
|
+
// the box edge stays visible even when the fill matches the page background.
|
|
1776
|
+
// Semi-transparent grey reads on both light and dark backgrounds without the
|
|
1777
|
+
// harsh contrast of a black/white border.
|
|
1778
|
+
const style =
|
|
1779
|
+
`background-color:${value};color:${fg};` +
|
|
1780
|
+
"border:1px solid rgba(128,128,128,0.5);border-radius:4px;padding:0.15em 0.9em;" +
|
|
1781
|
+
"font-family:'JetBrains Mono','Fira Code','Source Code Pro',monospace;font-size:0.95em;" +
|
|
1782
|
+
"-webkit-print-color-adjust:exact;print-color-adjust:exact";
|
|
1783
|
+
return `<span class="sdoc-color-swatch" style="${style}">${escapeHtml(value)}</span>`;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1731
1786
|
function renderInline(text) {
|
|
1732
1787
|
const nodes = parseInline(text);
|
|
1733
1788
|
return renderInlineNodes(nodes);
|
|
@@ -1766,6 +1821,8 @@ function renderInlineNodes(nodes) {
|
|
|
1766
1821
|
}
|
|
1767
1822
|
case "code":
|
|
1768
1823
|
return `<code class="sdoc-inline-code">${escapeHtml(node.value)}</code>`;
|
|
1824
|
+
case "color_swatch":
|
|
1825
|
+
return colorSwatchHtml(node.value);
|
|
1769
1826
|
case "em":
|
|
1770
1827
|
return `<em>${renderInlineNodes(node.children)}</em>`;
|
|
1771
1828
|
case "strong":
|
|
@@ -3773,5 +3830,7 @@ module.exports = {
|
|
|
3773
3830
|
renderKatex,
|
|
3774
3831
|
escapeHtml,
|
|
3775
3832
|
escapeAttr,
|
|
3776
|
-
sanitizeSvg
|
|
3833
|
+
sanitizeSvg,
|
|
3834
|
+
colorSwatchHtml,
|
|
3835
|
+
readableTextColor
|
|
3777
3836
|
};
|
package/src/slide-renderer.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// const { nodes, meta } = extractMeta(parsed.nodes);
|
|
8
8
|
// const html = renderSlides(nodes, { meta, themeCss, themeJs });
|
|
9
9
|
|
|
10
|
-
const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg } = require("./sdoc");
|
|
10
|
+
const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg, colorSwatchHtml } = require("./sdoc");
|
|
11
11
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Inline rendering — produces clean HTML without sdoc-* classes
|
|
@@ -21,6 +21,8 @@ function renderInlineNodes(nodes) {
|
|
|
21
21
|
return escapeHtml(node.value);
|
|
22
22
|
case "code":
|
|
23
23
|
return `<code>${escapeHtml(node.value)}</code>`;
|
|
24
|
+
case "color_swatch":
|
|
25
|
+
return colorSwatchHtml(node.value);
|
|
24
26
|
case "em":
|
|
25
27
|
return `<em>${renderInlineNodes(node.children)}</em>`;
|
|
26
28
|
case "strong":
|
|
@@ -206,18 +208,45 @@ function extractNotes(children) {
|
|
|
206
208
|
return { notes, contentNodes: rest };
|
|
207
209
|
}
|
|
208
210
|
|
|
211
|
+
// Separates :detail child scopes (drilldown / vertical slides) from other
|
|
212
|
+
// children. Detail scopes are NOT removed from rendering; they are emitted as
|
|
213
|
+
// sibling slides positioned vertically under the spine slide.
|
|
214
|
+
function extractDetails(children) {
|
|
215
|
+
const details = [];
|
|
216
|
+
const rest = [];
|
|
217
|
+
for (const child of children) {
|
|
218
|
+
if (child.type === "scope" && child.scopeType === "detail") {
|
|
219
|
+
details.push(child);
|
|
220
|
+
} else {
|
|
221
|
+
rest.push(child);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { details, contentNodes: rest };
|
|
225
|
+
}
|
|
226
|
+
|
|
209
227
|
// ---------------------------------------------------------------------------
|
|
210
228
|
// Slide rendering
|
|
211
229
|
// ---------------------------------------------------------------------------
|
|
212
230
|
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
// position: { spine: 1-based spine index, detail: 0 for spine, 1..N for details,
|
|
232
|
+
// totalSpines: total number of spine slides, hasDetails: bool (spine only) }
|
|
233
|
+
function renderSlide(scope, slideIndex, overlayHtml, position) {
|
|
234
|
+
// Pull :detail children out first so they don't appear inline in the spine
|
|
235
|
+
// slide's content; they're rendered as sibling vertical slides instead.
|
|
236
|
+
const { contentNodes: afterDetails } = extractDetails(scope.children);
|
|
237
|
+
const { config, contentNodes: afterConfig } = extractSlideConfig(afterDetails);
|
|
215
238
|
const { notes, contentNodes } = extractNotes(afterConfig);
|
|
216
239
|
|
|
217
240
|
const classes = ["slide"];
|
|
218
241
|
if (config.layout) {
|
|
219
242
|
classes.push(config.layout);
|
|
220
243
|
}
|
|
244
|
+
if (position && position.detail === 0 && position.hasDetails) {
|
|
245
|
+
classes.push("slide-has-details");
|
|
246
|
+
}
|
|
247
|
+
if (position && position.detail > 0) {
|
|
248
|
+
classes.push("slide-detail");
|
|
249
|
+
}
|
|
221
250
|
|
|
222
251
|
const title = scope.hasHeading !== false && scope.title
|
|
223
252
|
? `<h2>${renderInline(scope.title)}</h2>`
|
|
@@ -248,9 +277,25 @@ function renderSlide(scope, slideIndex, overlayHtml) {
|
|
|
248
277
|
: "";
|
|
249
278
|
|
|
250
279
|
const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
|
|
251
|
-
const overlay = overlayHtml || "";
|
|
252
280
|
|
|
253
|
-
|
|
281
|
+
// Drilldown metadata + slide indicator label (substituted into the footer)
|
|
282
|
+
let dataAttrs = "";
|
|
283
|
+
let indicatorLabel = "";
|
|
284
|
+
if (position) {
|
|
285
|
+
dataAttrs = ` data-spine="${position.spine}" data-detail="${position.detail}"`;
|
|
286
|
+
const denom = position.totalSpines;
|
|
287
|
+
indicatorLabel = position.detail === 0
|
|
288
|
+
? `${position.spine} / ${denom}`
|
|
289
|
+
: `${position.spine}.${position.detail} / ${denom}`;
|
|
290
|
+
}
|
|
291
|
+
const overlay = (overlayHtml || "").replace("__SLIDE_INDICATOR__", escapeHtml(indicatorLabel));
|
|
292
|
+
|
|
293
|
+
// Wrap title + body in a scale container so PDF export can apply
|
|
294
|
+
// transform: scale() to fit the content onto a fixed page size. In
|
|
295
|
+
// screen mode the wrapper is display:contents (invisible to layout); in
|
|
296
|
+
// print mode it becomes a real block that the beforeprint handler can
|
|
297
|
+
// measure and scale.
|
|
298
|
+
return `<div class="${classes.join(" ")}"${idAttr}${dataAttrs}>\n<div class="slide-content-scale">\n${title}\n${bodyHtml}\n</div>${notesHtml}${overlay}\n</div>`;
|
|
254
299
|
}
|
|
255
300
|
|
|
256
301
|
// ---------------------------------------------------------------------------
|
|
@@ -277,7 +322,11 @@ function renderSlides(nodes, options = {}) {
|
|
|
277
322
|
// Filter to scope nodes only (skip stray paragraphs and :comment scopes)
|
|
278
323
|
const slides = slideScopes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
|
|
279
324
|
|
|
280
|
-
// Build per-slide footer:
|
|
325
|
+
// Build per-slide footer:
|
|
326
|
+
// < CONFIDENTIAL ---gap--- Company N/Total >
|
|
327
|
+
// The indicator slot is rendered as a literal token here and substituted
|
|
328
|
+
// per-slide inside renderSlide() so all the right-edge elements share one
|
|
329
|
+
// flexbox row (avoids the page number stacking on top of the company name).
|
|
281
330
|
const footerParts = [];
|
|
282
331
|
footerParts.push(`<span class="nav-prev">‹</span>`);
|
|
283
332
|
if (meta.confidential) {
|
|
@@ -292,11 +341,42 @@ function renderSlides(nodes, options = {}) {
|
|
|
292
341
|
if (meta.company) {
|
|
293
342
|
footerParts.push(`<span class="sdoc-company-footer">${escapeHtml(meta.company)}</span>`);
|
|
294
343
|
}
|
|
344
|
+
footerParts.push(`<span class="slide-indicator">__SLIDE_INDICATOR__</span>`);
|
|
295
345
|
footerParts.push(`<span class="nav-next">›</span>`);
|
|
296
346
|
const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
|
|
297
347
|
|
|
298
|
-
|
|
299
|
-
|
|
348
|
+
// Build a flat emission order: each spine slide, followed immediately by its
|
|
349
|
+
// :detail children in source order. The flat order matches what we want for
|
|
350
|
+
// PDF export, so PDF needs no special case.
|
|
351
|
+
const totalSpines = slides.length;
|
|
352
|
+
const emitted = [];
|
|
353
|
+
slides.forEach((scope, i) => {
|
|
354
|
+
const spineIndex = i + 1; // 1-based
|
|
355
|
+
const { details } = extractDetails(scope.children);
|
|
356
|
+
emitted.push({
|
|
357
|
+
scope,
|
|
358
|
+
position: {
|
|
359
|
+
spine: spineIndex,
|
|
360
|
+
detail: 0,
|
|
361
|
+
totalSpines,
|
|
362
|
+
hasDetails: details.length > 0
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
details.forEach((detail, j) => {
|
|
366
|
+
emitted.push({
|
|
367
|
+
scope: detail,
|
|
368
|
+
position: {
|
|
369
|
+
spine: spineIndex,
|
|
370
|
+
detail: j + 1,
|
|
371
|
+
totalSpines,
|
|
372
|
+
hasDetails: false
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const slidesHtml = emitted
|
|
379
|
+
.map(({ scope, position }, index) => renderSlide(scope, index, overlayHtml, position))
|
|
300
380
|
.join("\n\n");
|
|
301
381
|
|
|
302
382
|
const title = meta.properties?.title
|
|
@@ -327,19 +407,39 @@ function renderSlides(nodes, options = {}) {
|
|
|
327
407
|
color: rgba(160, 40, 40, 0.6);
|
|
328
408
|
margin-left: 0.8em;
|
|
329
409
|
}
|
|
410
|
+
.slide-indicator {
|
|
411
|
+
font-size: 0.7em; color: rgba(0,0,0,0.35);
|
|
412
|
+
font-variant-numeric: tabular-nums;
|
|
413
|
+
letter-spacing: 0.04em;
|
|
414
|
+
pointer-events: none;
|
|
415
|
+
user-select: none;
|
|
416
|
+
margin-right: 0.6em;
|
|
417
|
+
}
|
|
418
|
+
/* Scale wrapper: invisible to layout in screen mode so existing slide
|
|
419
|
+
styles (flex centering, two-column grid, etc.) work as-is. In print
|
|
420
|
+
mode it becomes a real block element whose transform is set by the
|
|
421
|
+
beforeprint handler to shrink overflowing content to fit the page. */
|
|
422
|
+
.slide-content-scale { display: contents; }
|
|
423
|
+
|
|
330
424
|
@media print {
|
|
331
425
|
@page { size: 13.333in 7.5in; margin: 0; }
|
|
332
426
|
body { overflow: visible; height: auto; }
|
|
333
427
|
.slide {
|
|
334
|
-
display:
|
|
428
|
+
display: block !important;
|
|
335
429
|
position: relative !important;
|
|
336
430
|
opacity: 1 !important;
|
|
337
431
|
pointer-events: auto !important;
|
|
338
432
|
page-break-after: always; break-after: page;
|
|
339
433
|
width: 100vw; height: 100vh; max-width: none;
|
|
434
|
+
overflow: hidden;
|
|
340
435
|
page-break-inside: avoid; break-inside: avoid;
|
|
341
436
|
}
|
|
342
437
|
.slide:last-child { page-break-after: auto; break-after: auto; }
|
|
438
|
+
.slide-content-scale {
|
|
439
|
+
display: block;
|
|
440
|
+
width: 100%;
|
|
441
|
+
transform-origin: top left;
|
|
442
|
+
}
|
|
343
443
|
.nav-prev, .nav-next { display: none !important; }
|
|
344
444
|
.notes { display: none; }
|
|
345
445
|
}`;
|
|
@@ -359,6 +459,7 @@ blockquote p { color: #9d9d9d; }
|
|
|
359
459
|
.nav-prev, .nav-next { color: rgba(255, 255, 255, 0.7); }
|
|
360
460
|
.sdoc-company-footer { color: rgba(255, 255, 255, 0.35); }
|
|
361
461
|
.sdoc-confidential-notice { color: rgba(235, 120, 120, 0.7); }
|
|
462
|
+
.slide-indicator { color: rgba(255, 255, 255, 0.35); }
|
|
362
463
|
` : "";
|
|
363
464
|
|
|
364
465
|
const cssTag = `<style>\n${structuralCss}\n${themeCss}\n${darkCss}</style>`;
|
package/tools/build-doc.js
CHANGED
|
@@ -135,7 +135,7 @@ async function buildHtml(filePath, options = {}) {
|
|
|
135
135
|
|
|
136
136
|
const title = path.basename(resolvedPath, ".sdoc");
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
const html = renderHtmlDocumentFromParsed(
|
|
139
139
|
{ nodes: metaResult.nodes, errors: parsed.errors },
|
|
140
140
|
title,
|
|
141
141
|
{
|
|
@@ -146,6 +146,41 @@ async function buildHtml(filePath, options = {}) {
|
|
|
146
146
|
includeAbout,
|
|
147
147
|
}
|
|
148
148
|
);
|
|
149
|
+
|
|
150
|
+
// Inline local images as data URIs so output is self-contained. PDF export
|
|
151
|
+
// renders from a temp directory, where relative image paths (e.g.
|
|
152
|
+
// diagrams/foo.svg) no longer resolve; inlining also makes HTML portable.
|
|
153
|
+
return inlineLocalImages(html, docDir);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const IMAGE_MIME = {
|
|
157
|
+
".svg": "image/svg+xml",
|
|
158
|
+
".png": "image/png",
|
|
159
|
+
".jpg": "image/jpeg",
|
|
160
|
+
".jpeg": "image/jpeg",
|
|
161
|
+
".gif": "image/gif",
|
|
162
|
+
".webp": "image/webp",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function inlineLocalImages(html, docDir) {
|
|
166
|
+
return html.replace(/(<img\b[^>]*?\bsrc=")([^"]*)(")/gi, (match, pre, src, post) => {
|
|
167
|
+
// Leave remote URLs and already-inlined data URIs untouched.
|
|
168
|
+
if (/^(https?:|data:|file:)/i.test(src)) return match;
|
|
169
|
+
const decoded = src.replace(/&/g, "&");
|
|
170
|
+
const ext = path.extname(decoded).toLowerCase();
|
|
171
|
+
const mime = IMAGE_MIME[ext];
|
|
172
|
+
if (!mime) return match;
|
|
173
|
+
const abs = path.isAbsolute(decoded) ? decoded : path.join(docDir, decoded);
|
|
174
|
+
let data;
|
|
175
|
+
try {
|
|
176
|
+
data = fs.readFileSync(abs);
|
|
177
|
+
} catch {
|
|
178
|
+
console.error(`Warning: could not inline image (not found): ${decoded}`);
|
|
179
|
+
return match;
|
|
180
|
+
}
|
|
181
|
+
const uri = `data:${mime};base64,${data.toString("base64")}`;
|
|
182
|
+
return `${pre}${uri}${post}`;
|
|
183
|
+
});
|
|
149
184
|
}
|
|
150
185
|
|
|
151
186
|
async function main() {
|
|
@@ -197,6 +232,7 @@ async function main() {
|
|
|
197
232
|
outputPath = resolvedInput.replace(/\.sdoc$/i, "") + ".pdf";
|
|
198
233
|
}
|
|
199
234
|
const resolvedOutput = path.resolve(outputPath);
|
|
235
|
+
fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
|
|
200
236
|
|
|
201
237
|
// Write HTML to temp file for Chrome
|
|
202
238
|
const tmpHtml = path.join(os.tmpdir(), "sdoc-doc-" + Date.now() + ".html");
|