@markdstage/markdstage 2.4.0 → 2.5.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/README.md +8 -1
- package/package.json +1 -1
- package/shared/README.md +8 -5
- package/shared/renderer/renderer.js +11 -4
- package/shared/renderer/speaker-notes.mjs +252 -0
- package/shared/runtime/output.mjs +8 -0
- package/shared/runtime/pptx-package.mjs +266 -24
- package/src/cli.mjs +1 -0
package/README.md
CHANGED
|
@@ -21,6 +21,13 @@ npx @markdstage/markdstage present slides.md
|
|
|
21
21
|
npm install --global @markdstage/markdstage
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
For offline installation, download the versioned `.tgz` asset from the
|
|
25
|
+
[GitHub Release](https://github.com/runceel/markdstage/releases) and install it locally:
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
npm install --global .\markdstage-markdstage-<version>.tgz
|
|
29
|
+
```
|
|
30
|
+
|
|
24
31
|
## Commands
|
|
25
32
|
|
|
26
33
|
```console
|
|
@@ -43,7 +50,7 @@ markdstage skill install --target claude
|
|
|
43
50
|
| `validate` | Checks deck structure, Architecture DSL blocks, themes, and theme paths. |
|
|
44
51
|
| `inspect` | Reports the same compact 1280x720 clipping diagnostics as the canvas `inspect_layout` action. `--slide <n>` limits it to one page, `--all` includes slides that fit, `--fail-on-issues` exits with code 5. |
|
|
45
52
|
| `capture` | Writes 1280x720 PNG files. Without `--pages` only the slides reported as clipped are captured. |
|
|
46
|
-
| `export` | Produces the same 16:9 PDF or hybrid editable PowerPoint as the canvas Extension. The `--output` extension selects the format; PDF remains the default. |
|
|
53
|
+
| `export` | Produces the same 16:9 PDF or hybrid editable PowerPoint as the canvas Extension. PowerPoint output includes speaker-note Markdown as readable plain text notes. The `--output` extension selects the format; PDF remains the default. |
|
|
47
54
|
| `guide` | Prints the canonical `markdstage_guide` topics. |
|
|
48
55
|
| `skill` | Installs or checks the portable Agent Skills for Codex (`.agents/skills/markdstage/`), Claude Code (`.claude/skills/markdstage/`), and GitHub Copilot (`.github/skills/markdstage/`). Locally modified files are never overwritten without `--force`. |
|
|
49
56
|
| `help` | Shows the overview, or the help for one command. `markdstage help <command>` prints the same text as `markdstage <command> --help`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markdstage/markdstage",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Present, validate, inspect, capture, and export MarkdStage Markdown decks from the command line — no Copilot canvas required.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "runceel",
|
package/shared/README.md
CHANGED
|
@@ -73,8 +73,9 @@ The themed slide is displayed and updates automatically
|
|
|
73
73
|
`auto` measures standard slides without code, tables, images, or Mermaid and
|
|
74
74
|
enlarges only when ample space remains.
|
|
75
75
|
- Put **speaker notes** in top-level HTML comments on each slide. Presenter view
|
|
76
|
-
renders notes as Markdown and follows navigation.
|
|
77
|
-
regular slides, the external
|
|
76
|
+
renders notes as Markdown and follows navigation. PowerPoint export includes
|
|
77
|
+
readable plain-text notes. Notes are absent from regular slides, the external
|
|
78
|
+
presenter, and PDF output.
|
|
78
79
|
- The **canvas renderer** owns the compact navigation controls, shape editing,
|
|
79
80
|
the slide list, and current position. **More controls > Shape editing**
|
|
80
81
|
toggles the same placement mode as `edit_architecture`. For Markdown loaded
|
|
@@ -95,8 +96,10 @@ The themed slide is displayed and updates automatically
|
|
|
95
96
|
DSL objects as native PowerPoint content. Architecture nodes, groups, and
|
|
96
97
|
connector-label pills are visible AutoShapes with integrated text; icons use a
|
|
97
98
|
transparent foreground picture layer. Mermaid and unsupported styling stay
|
|
98
|
-
visible as background artwork and are listed in the export report.
|
|
99
|
-
|
|
99
|
+
visible as background artwork and are listed in the export report. Speaker
|
|
100
|
+
notes are converted from Markdown to readable plain text in the corresponding
|
|
101
|
+
PowerPoint notes pane. AI may call `export_pptx` with another
|
|
102
|
+
workspace-confined `.pptx` path.
|
|
100
103
|
- Use **More controls > Output preview** to letterbox the current slide inside
|
|
101
104
|
the canvas with
|
|
102
105
|
the same fixed 1280×720 typography, spacing, diagram limits, and clipping used
|
|
@@ -945,7 +948,7 @@ fails with `invalid_input`; pass the complete `slides` array or call
|
|
|
945
948
|
| `inspect_layout` | `{ index?: number, includeFits?: boolean }`. Render the registered in-memory PDF snapshot with the fixed 1280×720 output layout; this does not inspect the source file on disk. Omit `index` for one preferred whole-deck inspection. Serialize targeted calls because PDF, layout, and PNG jobs are exclusive. By default, return only clipped pages; `includeFits` includes successful pages. Returns dimensions, issue counts, overflow measurements, nested scroll containers, and a bounded list of element hints. Requires Edge, Chrome, or Chromium. |
|
|
946
949
|
| `capture_slides` | `{ indexes?: number[], outputDirectory?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Generate PDF-equivalent 1280×720 PNGs for at most 10 zero-based indexes. When `indexes` is omitted, inspect the deck and capture only clipped pages. Paths stay inside the workspace; results contain paths and layout summaries, not image bytes. Requires Edge, Chrome, or Chromium. |
|
|
947
950
|
| `export_pdf` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export one 16:9 page per slide. Relative paths use workspace root; default is `markdstage.pdf`. Theme affects PDF only. Reject paths outside workspace and non-`.pdf` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes }`. Requires Edge, Chrome, or Chromium. |
|
|
948
|
-
| `export_pptx` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export a hybrid editable 16:9 PowerPoint deck. Supported text, lists, links, tables, raster images, and Architecture DSL objects remain native. Architecture nodes, groups, and connector-label pills are visible AutoShapes with integrated text; icons are foreground pictures. Mermaid and unsupported visuals become reported background fallbacks. Relative paths use workspace root; default is `markdstage.pptx`. Reject paths outside workspace and non-`.pptx` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes, format, fallbackCount, fallbacks }`. Requires Edge, Chrome, or Chromium. |
|
|
951
|
+
| `export_pptx` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export a hybrid editable 16:9 PowerPoint deck. Supported text, lists, links, tables, raster images, and Architecture DSL objects remain native. Speaker-note Markdown is exported as readable plain text in the corresponding PowerPoint notes pane. Architecture nodes, groups, and connector-label pills are visible AutoShapes with integrated text; icons are foreground pictures. Mermaid and unsupported visuals become reported background fallbacks. Relative paths use workspace root; default is `markdstage.pptx`. Reject paths outside workspace and non-`.pptx` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes, format, fallbackCount, fallbacks }`. Requires Edge, Chrome, or Chromium. |
|
|
949
952
|
| `edit_architecture` | `{ enabled: boolean }`. Toggle placement editing. Imported decks also write to the source fence; direct decks write to canvas state. Presenter/print omit UI. Mode is not persisted and `reset` disables it. Returns `{ ok, enabled, version }`. |
|
|
950
953
|
| `reset` | No input. Clear deck/slide state, disable editing, and return to the waiting view. |
|
|
951
954
|
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from "./theme.mjs";
|
|
8
8
|
import {
|
|
9
9
|
extractSpeakerNotes,
|
|
10
|
+
speakerNotesToPlainText,
|
|
11
|
+
splitSpeakerNotes,
|
|
10
12
|
stripSpeakerNotes,
|
|
11
13
|
} from "./speaker-notes.mjs";
|
|
12
14
|
import { splitImportPath } from "./import-path.mjs";
|
|
@@ -604,7 +606,8 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
|
|
|
604
606
|
const md = placeholder ? PLACEHOLDER : markdown;
|
|
605
607
|
const { meta, body: rawBody } = splitFrontMatter(md);
|
|
606
608
|
const directive = extractSlideSizeDirective(rawBody);
|
|
607
|
-
const
|
|
609
|
+
const speakerNotes = splitSpeakerNotes(directive.body);
|
|
610
|
+
const body = speakerNotes.markdown;
|
|
608
611
|
|
|
609
612
|
const layout = (meta.layout || "").toLowerCase();
|
|
610
613
|
const titleSlide = layout === "title";
|
|
@@ -771,6 +774,7 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
|
|
|
771
774
|
sectionSlide,
|
|
772
775
|
centerSlide,
|
|
773
776
|
backcoverSlide,
|
|
777
|
+
speakerNotes: speakerNotes.notes,
|
|
774
778
|
title: meta.title || meta.deck || "Slide",
|
|
775
779
|
};
|
|
776
780
|
}
|
|
@@ -1583,6 +1587,9 @@ async function collectPptxSlide(slide, index) {
|
|
|
1583
1587
|
: undefined,
|
|
1584
1588
|
});
|
|
1585
1589
|
if (!paragraph.runs.some((run) => run.text.trim())) continue;
|
|
1590
|
+
const disableTextWrap =
|
|
1591
|
+
element.classList.contains("kicker") ||
|
|
1592
|
+
(element.matches("h1, h2, .slide-title") && renderedTextLineCount(element) === 1);
|
|
1586
1593
|
elements.push({
|
|
1587
1594
|
type: "text",
|
|
1588
1595
|
path: elementPath(element, deck),
|
|
@@ -1591,9 +1598,7 @@ async function collectPptxSlide(slide, index) {
|
|
|
1591
1598
|
: relativeBounds(element, deck)),
|
|
1592
1599
|
paragraphs: [paragraph],
|
|
1593
1600
|
opacity: Number(getComputedStyle(element).opacity) || 1,
|
|
1594
|
-
...(
|
|
1595
|
-
? { textWrap: "none" }
|
|
1596
|
-
: {}),
|
|
1601
|
+
...(disableTextWrap ? { textWrap: "none" } : {}),
|
|
1597
1602
|
});
|
|
1598
1603
|
element.setAttribute("data-pptx-native", "text");
|
|
1599
1604
|
}
|
|
@@ -1749,6 +1754,7 @@ async function collectPptxSlide(slide, index) {
|
|
|
1749
1754
|
? "backcover"
|
|
1750
1755
|
: "standard";
|
|
1751
1756
|
const visibleTitle = deck.querySelector("h1, h2")?.textContent?.trim();
|
|
1757
|
+
const notes = speakerNotesToPlainText(slide.speakerNotes, window.marked, document);
|
|
1752
1758
|
return {
|
|
1753
1759
|
index,
|
|
1754
1760
|
layout,
|
|
@@ -1756,6 +1762,7 @@ async function collectPptxSlide(slide, index) {
|
|
|
1756
1762
|
title: visibleTitle || slide.title,
|
|
1757
1763
|
width: OUTPUT_WIDTH,
|
|
1758
1764
|
height: OUTPUT_HEIGHT,
|
|
1765
|
+
...(notes ? { notes } : {}),
|
|
1759
1766
|
elements,
|
|
1760
1767
|
fallbacks,
|
|
1761
1768
|
};
|
|
@@ -18,6 +18,253 @@ function normalizeNote(text) {
|
|
|
18
18
|
return lines.map((line) => line.slice(indent)).join("\n").trim();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function isXmlCodePoint(codePoint) {
|
|
22
|
+
return (
|
|
23
|
+
codePoint === 0x9 ||
|
|
24
|
+
codePoint === 0xa ||
|
|
25
|
+
codePoint === 0xd ||
|
|
26
|
+
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
|
27
|
+
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
|
28
|
+
(codePoint >= 0x10000 && codePoint <= 0x10ffff)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function xmlSafeText(text) {
|
|
33
|
+
return [...text]
|
|
34
|
+
.map((character) => (isXmlCodePoint(character.codePointAt(0)) ? character : "\uFFFD"))
|
|
35
|
+
.join("");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decodeEntities(text) {
|
|
39
|
+
const named = new Map([
|
|
40
|
+
["amp", "&"],
|
|
41
|
+
["apos", "'"],
|
|
42
|
+
["gt", ">"],
|
|
43
|
+
["lt", "<"],
|
|
44
|
+
["nbsp", " "],
|
|
45
|
+
["quot", '"'],
|
|
46
|
+
]);
|
|
47
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, value) => {
|
|
48
|
+
if (value[0] !== "#") return named.get(value.toLowerCase()) ?? entity;
|
|
49
|
+
const codePoint =
|
|
50
|
+
value[1]?.toLowerCase() === "x"
|
|
51
|
+
? Number.parseInt(value.slice(2), 16)
|
|
52
|
+
: Number.parseInt(value.slice(1), 10);
|
|
53
|
+
if (!Number.isSafeInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
|
54
|
+
return entity;
|
|
55
|
+
}
|
|
56
|
+
return isXmlCodePoint(codePoint) ? String.fromCodePoint(codePoint) : "\uFFFD";
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function htmlEntityDecoder(documentRef) {
|
|
61
|
+
if (!documentRef || typeof documentRef.createElement !== "function") {
|
|
62
|
+
return decodeEntities;
|
|
63
|
+
}
|
|
64
|
+
const textarea = documentRef.createElement("textarea");
|
|
65
|
+
return (text) => {
|
|
66
|
+
textarea.innerHTML = text;
|
|
67
|
+
return textarea.value;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function htmlTagEnd(value, start) {
|
|
72
|
+
let quote = "";
|
|
73
|
+
for (let index = start + 1; index < value.length; index += 1) {
|
|
74
|
+
const character = value[index];
|
|
75
|
+
if (quote) {
|
|
76
|
+
if (character === quote) quote = "";
|
|
77
|
+
} else if (character === '"' || character === "'") {
|
|
78
|
+
quote = character;
|
|
79
|
+
} else if (character === ">") {
|
|
80
|
+
return index;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return -1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function htmlTokenText(value, decodeHtml) {
|
|
87
|
+
const blockTags = new Set([
|
|
88
|
+
"address",
|
|
89
|
+
"article",
|
|
90
|
+
"aside",
|
|
91
|
+
"blockquote",
|
|
92
|
+
"div",
|
|
93
|
+
"h1",
|
|
94
|
+
"h2",
|
|
95
|
+
"h3",
|
|
96
|
+
"h4",
|
|
97
|
+
"h5",
|
|
98
|
+
"h6",
|
|
99
|
+
"li",
|
|
100
|
+
"p",
|
|
101
|
+
"pre",
|
|
102
|
+
"section",
|
|
103
|
+
"tr",
|
|
104
|
+
]);
|
|
105
|
+
let output = "";
|
|
106
|
+
let cursor = 0;
|
|
107
|
+
while (cursor < value.length) {
|
|
108
|
+
const start = value.indexOf("<", cursor);
|
|
109
|
+
if (start < 0) {
|
|
110
|
+
output += value.slice(cursor);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
output += value.slice(cursor, start);
|
|
114
|
+
if (value.startsWith("<!--", start)) {
|
|
115
|
+
const end = value.indexOf("-->", start + 4);
|
|
116
|
+
cursor = end < 0 ? value.length : end + 3;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const end = htmlTagEnd(value, start);
|
|
120
|
+
if (end < 0) {
|
|
121
|
+
output += value.slice(start);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
const source = value.slice(start + 1, end).trim();
|
|
125
|
+
const closing = source.startsWith("/");
|
|
126
|
+
const name = /^\/?\s*([a-z][a-z0-9-]*)/i.exec(source)?.[1]?.toLowerCase();
|
|
127
|
+
if (!name) {
|
|
128
|
+
output += value.slice(start, end + 1);
|
|
129
|
+
} else if (name === "br") {
|
|
130
|
+
output += "\n";
|
|
131
|
+
} else if (!closing && name === "li") {
|
|
132
|
+
output += "• ";
|
|
133
|
+
} else if (closing && blockTags.has(name)) {
|
|
134
|
+
output += "\n";
|
|
135
|
+
}
|
|
136
|
+
cursor = end + 1;
|
|
137
|
+
}
|
|
138
|
+
return decodeHtml(output);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function inlineTokensText(tokens, decodeHtml) {
|
|
142
|
+
return (Array.isArray(tokens) ? tokens : [])
|
|
143
|
+
.map((token) => {
|
|
144
|
+
if (!token || typeof token !== "object") return "";
|
|
145
|
+
if (token.type === "br") return "\n";
|
|
146
|
+
if (token.type === "codespan" || token.type === "escape") {
|
|
147
|
+
return decodeHtml(token.text || "");
|
|
148
|
+
}
|
|
149
|
+
if (token.type === "image") return decodeHtml(token.text || "");
|
|
150
|
+
if (token.type === "html") {
|
|
151
|
+
return htmlTokenText(token.text || token.raw || "", decodeHtml);
|
|
152
|
+
}
|
|
153
|
+
if (token.type === "link") {
|
|
154
|
+
const label =
|
|
155
|
+
inlineTokensText(token.tokens, decodeHtml) ||
|
|
156
|
+
decodeHtml(token.text || token.href || "");
|
|
157
|
+
const href = decodeHtml(token.href || "");
|
|
158
|
+
if (!href || href === label || href === `mailto:${label}`) return label;
|
|
159
|
+
return `${label} (${href})`;
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(token.tokens)) return inlineTokensText(token.tokens, decodeHtml);
|
|
162
|
+
return decodeHtml(token.text || "");
|
|
163
|
+
})
|
|
164
|
+
.join("");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function listTokenText(token, depth, decodeHtml) {
|
|
168
|
+
const orderedStart = Number.isInteger(Number(token.start)) ? Number(token.start) : 1;
|
|
169
|
+
return token.items
|
|
170
|
+
.map((item, itemIndex) => {
|
|
171
|
+
const marker = item.task
|
|
172
|
+
? item.checked
|
|
173
|
+
? "☒ "
|
|
174
|
+
: "☐ "
|
|
175
|
+
: token.ordered
|
|
176
|
+
? `${orderedStart + itemIndex}. `
|
|
177
|
+
: "• ";
|
|
178
|
+
const indent = " ".repeat(depth);
|
|
179
|
+
const continuation = `${indent}${" ".repeat(marker.length)}`;
|
|
180
|
+
const lines = [];
|
|
181
|
+
let started = false;
|
|
182
|
+
for (const child of item.tokens || []) {
|
|
183
|
+
if (child.type === "space") continue;
|
|
184
|
+
if (child.type === "list") {
|
|
185
|
+
lines.push(listTokenText(child, depth + 1, decodeHtml));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const text = blockTokenText(child, depth, decodeHtml).trim();
|
|
189
|
+
if (!text) continue;
|
|
190
|
+
const childLines = text.split("\n");
|
|
191
|
+
if (!started) {
|
|
192
|
+
lines.push(`${indent}${marker}${childLines.shift() || ""}`);
|
|
193
|
+
started = true;
|
|
194
|
+
}
|
|
195
|
+
lines.push(...childLines.map((line) => continuation + line));
|
|
196
|
+
if (started && child.type === "paragraph" && lines.length > 1) {
|
|
197
|
+
lines.push("");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (lines.at(-1) === "") lines.pop();
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
})
|
|
203
|
+
.join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function tableTokenText(token, decodeHtml) {
|
|
207
|
+
const rows = [
|
|
208
|
+
token.header || [],
|
|
209
|
+
...(Array.isArray(token.rows) ? token.rows : []),
|
|
210
|
+
];
|
|
211
|
+
return rows
|
|
212
|
+
.map((row) =>
|
|
213
|
+
row.map((cell) => inlineTokensText(cell.tokens, decodeHtml)).join(" | "),
|
|
214
|
+
)
|
|
215
|
+
.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function blockTokenText(token, listDepth, decodeHtml) {
|
|
219
|
+
if (!token || typeof token !== "object") return "";
|
|
220
|
+
if (["heading", "paragraph", "text"].includes(token.type)) {
|
|
221
|
+
return inlineTokensText(token.tokens, decodeHtml) || decodeHtml(token.text || "");
|
|
222
|
+
}
|
|
223
|
+
if (token.type === "code") return token.text || "";
|
|
224
|
+
if (token.type === "blockquote") {
|
|
225
|
+
return blockTokensText(token.tokens, listDepth, decodeHtml);
|
|
226
|
+
}
|
|
227
|
+
if (token.type === "list") return listTokenText(token, listDepth, decodeHtml);
|
|
228
|
+
if (token.type === "table") return tableTokenText(token, decodeHtml);
|
|
229
|
+
if (token.type === "html") {
|
|
230
|
+
return htmlTokenText(token.text || token.raw || "", decodeHtml);
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(token.tokens)) {
|
|
233
|
+
return blockTokensText(token.tokens, listDepth, decodeHtml);
|
|
234
|
+
}
|
|
235
|
+
return token.type === "space" || token.type === "hr"
|
|
236
|
+
? ""
|
|
237
|
+
: decodeHtml(token.text || "");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function blockTokensText(tokens, listDepth, decodeHtml) {
|
|
241
|
+
return (Array.isArray(tokens) ? tokens : [])
|
|
242
|
+
.map((token) => blockTokenText(token, listDepth, decodeHtml).trimEnd())
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.join("\n\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Convert speaker-note Markdown into readable plain text for PowerPoint notes.
|
|
249
|
+
*
|
|
250
|
+
* Paragraphs, list indentation, code contents, link destinations, and table
|
|
251
|
+
* columns remain readable while presentation-only Markdown markers are removed.
|
|
252
|
+
*/
|
|
253
|
+
export function speakerNotesToPlainText(
|
|
254
|
+
markdown,
|
|
255
|
+
markedApi = globalThis.marked,
|
|
256
|
+
documentRef = globalThis.document,
|
|
257
|
+
) {
|
|
258
|
+
if (!markedApi || typeof markedApi.lexer !== "function") {
|
|
259
|
+
throw new TypeError("A Marked lexer is required to convert speaker notes.");
|
|
260
|
+
}
|
|
261
|
+
const tokens = markedApi.lexer(normalizeText(markdown), { gfm: true });
|
|
262
|
+
const decodeHtml = htmlEntityDecoder(documentRef);
|
|
263
|
+
return xmlSafeText(blockTokensText(tokens, 0, decodeHtml))
|
|
264
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
265
|
+
.trim();
|
|
266
|
+
}
|
|
267
|
+
|
|
21
268
|
function closesFence(line, fence) {
|
|
22
269
|
const indentation = line.match(/^[ \t]*/)?.[0].length ?? 0;
|
|
23
270
|
if (indentation > 3) return false;
|
|
@@ -104,3 +351,8 @@ export function extractSpeakerNotes(markdown) {
|
|
|
104
351
|
export function stripSpeakerNotes(markdown) {
|
|
105
352
|
return parseSpeakerNotes(markdown).markdown;
|
|
106
353
|
}
|
|
354
|
+
|
|
355
|
+
/** Return visible Markdown and speaker notes from one parser pass. */
|
|
356
|
+
export function splitSpeakerNotes(markdown) {
|
|
357
|
+
return parseSpeakerNotes(markdown);
|
|
358
|
+
}
|
|
@@ -173,6 +173,9 @@ export async function preparePptxPackageModel(
|
|
|
173
173
|
if (!sourceSlide || !Array.isArray(sourceSlide.elements)) {
|
|
174
174
|
throw new Error(`PowerPoint slide ${slideIndex + 1} has an invalid element list.`);
|
|
175
175
|
}
|
|
176
|
+
if (sourceSlide.notes !== undefined && typeof sourceSlide.notes !== "string") {
|
|
177
|
+
throw new Error(`PowerPoint slide ${slideIndex + 1} has invalid speaker notes.`);
|
|
178
|
+
}
|
|
176
179
|
const elements = [];
|
|
177
180
|
for (const sourceElement of sourceSlide.elements) {
|
|
178
181
|
if (sourceElement?.type !== "image") {
|
|
@@ -210,6 +213,7 @@ export async function preparePptxPackageModel(
|
|
|
210
213
|
}
|
|
211
214
|
slides.push({
|
|
212
215
|
backgroundAssetId: `markdstage-background-${slideIndex + 1}`,
|
|
216
|
+
...(sourceSlide.notes ? { notes: sourceSlide.notes } : {}),
|
|
213
217
|
elements,
|
|
214
218
|
});
|
|
215
219
|
}
|
|
@@ -594,9 +598,13 @@ export async function exportPptx(
|
|
|
594
598
|
...packageModel,
|
|
595
599
|
});
|
|
596
600
|
const packageSummary = inspectPackage(buffer);
|
|
601
|
+
const expectedNotes = model.slides.filter(
|
|
602
|
+
(slide) => typeof slide.notes === "string" && slide.notes.trim(),
|
|
603
|
+
).length;
|
|
597
604
|
if (
|
|
598
605
|
!packageSummary.valid ||
|
|
599
606
|
packageSummary.slideCount !== snapshot.slides.length ||
|
|
607
|
+
packageSummary.notesCount !== expectedNotes ||
|
|
600
608
|
packageSummary.dimensions.widthEmu !== PPTX_DIMENSIONS.widthEmu ||
|
|
601
609
|
packageSummary.dimensions.heightEmu !== PPTX_DIMENSIONS.heightEmu
|
|
602
610
|
) {
|
|
@@ -16,6 +16,8 @@ const NS_R =
|
|
|
16
16
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
17
17
|
const NS_REL =
|
|
18
18
|
"http://schemas.openxmlformats.org/package/2006/relationships";
|
|
19
|
+
const JAPANESE_FONT_FACE = "Yu Gothic";
|
|
20
|
+
const HUNDREDTH_POINTS_PER_PIXEL = 75;
|
|
19
21
|
|
|
20
22
|
const REL = {
|
|
21
23
|
officeDocument: `${NS_R}/officeDocument`,
|
|
@@ -27,6 +29,8 @@ const REL = {
|
|
|
27
29
|
theme: `${NS_R}/theme`,
|
|
28
30
|
image: `${NS_R}/image`,
|
|
29
31
|
hyperlink: `${NS_R}/hyperlink`,
|
|
32
|
+
notesSlide: `${NS_R}/notesSlide`,
|
|
33
|
+
notesMaster: `${NS_R}/notesMaster`,
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
const CONTENT_TYPES = {
|
|
@@ -49,8 +53,21 @@ function fail(message) {
|
|
|
49
53
|
throw new TypeError(`Invalid PowerPoint model: ${message}`);
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
function isXmlCodePoint(codePoint) {
|
|
57
|
+
return (
|
|
58
|
+
codePoint === 0x9 ||
|
|
59
|
+
codePoint === 0xa ||
|
|
60
|
+
codePoint === 0xd ||
|
|
61
|
+
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
|
62
|
+
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
|
63
|
+
(codePoint >= 0x10000 && codePoint <= 0x10ffff)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
function xmlEscape(value) {
|
|
53
|
-
return String(value)
|
|
68
|
+
return [...String(value)]
|
|
69
|
+
.map((character) => (isXmlCodePoint(character.codePointAt(0)) ? character : "\uFFFD"))
|
|
70
|
+
.join("")
|
|
54
71
|
.replaceAll("&", "&")
|
|
55
72
|
.replaceAll("<", "<")
|
|
56
73
|
.replaceAll(">", ">")
|
|
@@ -333,7 +350,26 @@ function normalizeText(value, path) {
|
|
|
333
350
|
return value;
|
|
334
351
|
}
|
|
335
352
|
|
|
336
|
-
function
|
|
353
|
+
function bulletTextOffsetPx(paragraph, path) {
|
|
354
|
+
if (
|
|
355
|
+
!paragraph ||
|
|
356
|
+
typeof paragraph !== "object" ||
|
|
357
|
+
Array.isArray(paragraph) ||
|
|
358
|
+
!paragraph.bullet ||
|
|
359
|
+
!Array.isArray(paragraph.runs) ||
|
|
360
|
+
paragraph.runs.length === 0
|
|
361
|
+
) {
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
const largestRunSize = Math.max(
|
|
365
|
+
...paragraph.runs.map((run, index) =>
|
|
366
|
+
fontSizeOf(run, `${path}.runs[${index}]`),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
return largestRunSize / HUNDREDTH_POINTS_PER_PIXEL;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function paragraphXml(paragraph, path, relationships, leftMarginPx = 0) {
|
|
337
373
|
if (!paragraph || typeof paragraph !== "object" || Array.isArray(paragraph)) {
|
|
338
374
|
fail(`${path} must be an object`);
|
|
339
375
|
}
|
|
@@ -359,12 +395,21 @@ function paragraphXml(paragraph, path, relationships) {
|
|
|
359
395
|
: paragraph.bullet.character || "•";
|
|
360
396
|
bullet = `<a:buChar char="${xmlEscape(character)}"/>`;
|
|
361
397
|
}
|
|
398
|
+
const bulletOffsetPx = bulletTextOffsetPx(paragraph, path);
|
|
399
|
+
const paragraphMarginPx = Math.max(leftMarginPx, bulletOffsetPx);
|
|
400
|
+
const indentation = [
|
|
401
|
+
paragraphMarginPx > 0 ? `marL="${emu(paragraphMarginPx)}"` : "",
|
|
402
|
+
bulletOffsetPx > 0 ? `indent="-${emu(bulletOffsetPx)}"` : "",
|
|
403
|
+
]
|
|
404
|
+
.filter(Boolean)
|
|
405
|
+
.join(" ");
|
|
406
|
+
const indentationAttributes = indentation ? ` ${indentation}` : "";
|
|
362
407
|
const runs = paragraph.runs
|
|
363
408
|
.map((run, index) =>
|
|
364
409
|
runXml(run, `${path}.runs[${index}]`, relationships),
|
|
365
410
|
)
|
|
366
411
|
.join("");
|
|
367
|
-
return `<a:p><a:pPr algn="${align}" lvl="${level}">${bullet}</a:pPr>${runs}<a:endParaRPr lang="en-US"/></a:p>`;
|
|
412
|
+
return `<a:p><a:pPr algn="${align}" lvl="${level}"${indentationAttributes}>${bullet}</a:pPr>${runs}<a:endParaRPr lang="en-US" noProof="1"><a:ea typeface="${JAPANESE_FONT_FACE}"/></a:endParaRPr></a:p>`;
|
|
368
413
|
}
|
|
369
414
|
|
|
370
415
|
function runXml(run, path, relationships) {
|
|
@@ -375,6 +420,7 @@ function runXml(run, path, relationships) {
|
|
|
375
420
|
const size = fontSizeOf(run, path);
|
|
376
421
|
const attributes = [
|
|
377
422
|
'lang="en-US"',
|
|
423
|
+
'noProof="1"',
|
|
378
424
|
`sz="${size}"`,
|
|
379
425
|
run.bold ? 'b="1"' : "",
|
|
380
426
|
run.italic ? 'i="1"' : "",
|
|
@@ -393,6 +439,7 @@ function runXml(run, path, relationships) {
|
|
|
393
439
|
}
|
|
394
440
|
properties += `<a:latin typeface="${xmlEscape(run.fontFace)}"/>`;
|
|
395
441
|
}
|
|
442
|
+
properties += `<a:ea typeface="${JAPANESE_FONT_FACE}"/>`;
|
|
396
443
|
if (run.href !== undefined) {
|
|
397
444
|
if (typeof run.href !== "string" || !run.href) {
|
|
398
445
|
fail(`${path}.href must be a non-empty string`);
|
|
@@ -446,11 +493,17 @@ function textBodyXml(
|
|
|
446
493
|
tag = "p:txBody",
|
|
447
494
|
bodyOptions = {},
|
|
448
495
|
bodyPath = path,
|
|
496
|
+
leftMarginPx = 0,
|
|
449
497
|
) {
|
|
450
498
|
const text = normalizeText(value, path);
|
|
451
499
|
const paragraphs = text.paragraphs
|
|
452
500
|
.map((paragraph, index) =>
|
|
453
|
-
paragraphXml(
|
|
501
|
+
paragraphXml(
|
|
502
|
+
paragraph,
|
|
503
|
+
`${path}.paragraphs[${index}]`,
|
|
504
|
+
relationships,
|
|
505
|
+
leftMarginPx,
|
|
506
|
+
),
|
|
454
507
|
)
|
|
455
508
|
.join("");
|
|
456
509
|
return `<${tag}>${textBodyPropertiesXml(bodyOptions, bodyPath)}<a:lstStyle/>${paragraphs}</${tag}>`;
|
|
@@ -463,12 +516,35 @@ function shapeBase(id, name, bounds, properties, text = "") {
|
|
|
463
516
|
function textShapeXml(element, path, id, relationships) {
|
|
464
517
|
const bounds = boundsOf(element, path);
|
|
465
518
|
const text = { paragraphs: element.paragraphs };
|
|
519
|
+
const paragraphs = Array.isArray(text.paragraphs) ? text.paragraphs : [];
|
|
520
|
+
const bulletInsetPx = Math.max(
|
|
521
|
+
0,
|
|
522
|
+
...paragraphs.map((paragraph, index) =>
|
|
523
|
+
bulletTextOffsetPx(paragraph, `${path}.paragraphs[${index}]`),
|
|
524
|
+
),
|
|
525
|
+
);
|
|
526
|
+
const adjustedBounds =
|
|
527
|
+
bulletInsetPx > 0
|
|
528
|
+
? {
|
|
529
|
+
...bounds,
|
|
530
|
+
x: bounds.x - bulletInsetPx,
|
|
531
|
+
width: bounds.width + bulletInsetPx,
|
|
532
|
+
}
|
|
533
|
+
: bounds;
|
|
466
534
|
return shapeBase(
|
|
467
535
|
id,
|
|
468
536
|
`Text ${id}`,
|
|
469
|
-
|
|
537
|
+
adjustedBounds,
|
|
470
538
|
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>',
|
|
471
|
-
textBodyXml(
|
|
539
|
+
textBodyXml(
|
|
540
|
+
text,
|
|
541
|
+
`${path}`,
|
|
542
|
+
relationships,
|
|
543
|
+
"p:txBody",
|
|
544
|
+
element,
|
|
545
|
+
path,
|
|
546
|
+
bulletInsetPx,
|
|
547
|
+
),
|
|
472
548
|
);
|
|
473
549
|
}
|
|
474
550
|
|
|
@@ -663,7 +739,7 @@ function connectorXml(element, path, nextId, relationships) {
|
|
|
663
739
|
return shapes.join("");
|
|
664
740
|
}
|
|
665
741
|
|
|
666
|
-
function relationshipRegistry() {
|
|
742
|
+
function relationshipRegistry(notesSlideNumber = null) {
|
|
667
743
|
const entries = [
|
|
668
744
|
{
|
|
669
745
|
id: "rId1",
|
|
@@ -672,6 +748,14 @@ function relationshipRegistry() {
|
|
|
672
748
|
external: false,
|
|
673
749
|
},
|
|
674
750
|
];
|
|
751
|
+
if (notesSlideNumber !== null) {
|
|
752
|
+
entries.push({
|
|
753
|
+
id: `rId${entries.length + 1}`,
|
|
754
|
+
type: REL.notesSlide,
|
|
755
|
+
target: `../notesSlides/notesSlide${notesSlideNumber}.xml`,
|
|
756
|
+
external: false,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
675
759
|
const images = new Map();
|
|
676
760
|
const hyperlinks = new Map();
|
|
677
761
|
const add = (type, target, external) => {
|
|
@@ -705,13 +789,13 @@ function baseShapeTree() {
|
|
|
705
789
|
return '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>';
|
|
706
790
|
}
|
|
707
791
|
|
|
708
|
-
function buildSlide(slide, slideIndex, assets) {
|
|
792
|
+
function buildSlide(slide, slideIndex, assets, notesSlideNumber = null) {
|
|
709
793
|
const path = `slides[${slideIndex}]`;
|
|
710
794
|
if (!slide || typeof slide !== "object" || Array.isArray(slide)) {
|
|
711
795
|
fail(`${path} must be an object`);
|
|
712
796
|
}
|
|
713
797
|
if (!Array.isArray(slide.elements)) fail(`${path}.elements must be an array`);
|
|
714
|
-
const relationships = relationshipRegistry();
|
|
798
|
+
const relationships = relationshipRegistry(notesSlideNumber);
|
|
715
799
|
let shapeId = 2;
|
|
716
800
|
const nextId = () => shapeId++;
|
|
717
801
|
const shapes = [];
|
|
@@ -785,7 +869,7 @@ function relationshipsXml(entries) {
|
|
|
785
869
|
.join("")}</Relationships>`;
|
|
786
870
|
}
|
|
787
871
|
|
|
788
|
-
function contentTypesXml(slideCount, assets) {
|
|
872
|
+
function contentTypesXml(slideCount, assets, notesCount) {
|
|
789
873
|
const imageTypes = new Map();
|
|
790
874
|
for (const asset of assets.values()) {
|
|
791
875
|
imageTypes.set(asset.extension, asset.contentType);
|
|
@@ -801,19 +885,36 @@ function contentTypesXml(slideCount, assets) {
|
|
|
801
885
|
(_, index) =>
|
|
802
886
|
`<Override PartName="/ppt/slides/slide${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`,
|
|
803
887
|
).join("");
|
|
804
|
-
|
|
888
|
+
const notes = Array.from(
|
|
889
|
+
{ length: notesCount },
|
|
890
|
+
(_, index) =>
|
|
891
|
+
`<Override PartName="/ppt/notesSlides/notesSlide${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>`,
|
|
892
|
+
).join("");
|
|
893
|
+
const notesMaster =
|
|
894
|
+
notesCount > 0
|
|
895
|
+
? '<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>'
|
|
896
|
+
: "";
|
|
897
|
+
const notesTheme =
|
|
898
|
+
notesCount > 0
|
|
899
|
+
? '<Override PartName="/ppt/theme/theme2.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>'
|
|
900
|
+
: "";
|
|
901
|
+
return `${XML}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${defaults}<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/><Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>${notesTheme}${slides}${notesMaster}${notes}</Types>`;
|
|
805
902
|
}
|
|
806
903
|
|
|
807
|
-
function presentationXml(slideCount) {
|
|
904
|
+
function presentationXml(slideCount, notesCount) {
|
|
808
905
|
const slides = Array.from(
|
|
809
906
|
{ length: slideCount },
|
|
810
907
|
(_, index) =>
|
|
811
908
|
`<p:sldId id="${256 + index}" r:id="rId${index + 2}"/>`,
|
|
812
909
|
).join("");
|
|
813
|
-
|
|
910
|
+
const notesMaster =
|
|
911
|
+
notesCount > 0
|
|
912
|
+
? `<p:notesMasterIdLst><p:notesMasterId r:id="rId${slideCount + 2}"/></p:notesMasterIdLst>`
|
|
913
|
+
: "";
|
|
914
|
+
return `${XML}<p:presentation xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>${notesMaster}<p:sldIdLst>${slides}</p:sldIdLst><p:sldSz cx="${PPTX_DIMENSIONS.widthEmu}" cy="${PPTX_DIMENSIONS.heightEmu}" type="screen16x9"/><p:notesSz cx="6858000" cy="9144000"/><p:defaultTextStyle/></p:presentation>`;
|
|
814
915
|
}
|
|
815
916
|
|
|
816
|
-
function presentationRelsXml(slideCount) {
|
|
917
|
+
function presentationRelsXml(slideCount, notesCount) {
|
|
817
918
|
const entries = [
|
|
818
919
|
{
|
|
819
920
|
id: "rId1",
|
|
@@ -826,6 +927,13 @@ function presentationRelsXml(slideCount) {
|
|
|
826
927
|
target: `slides/slide${index + 1}.xml`,
|
|
827
928
|
})),
|
|
828
929
|
];
|
|
930
|
+
if (notesCount > 0) {
|
|
931
|
+
entries.push({
|
|
932
|
+
id: `rId${slideCount + 2}`,
|
|
933
|
+
type: REL.notesMaster,
|
|
934
|
+
target: "notesMasters/notesMaster1.xml",
|
|
935
|
+
});
|
|
936
|
+
}
|
|
829
937
|
return relationshipsXml(entries);
|
|
830
938
|
}
|
|
831
939
|
|
|
@@ -833,12 +941,12 @@ function coreXml(title) {
|
|
|
833
941
|
return `${XML}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(title)}</dc:title><dc:creator>MarkdStage</dc:creator><cp:lastModifiedBy>MarkdStage</cp:lastModifiedBy></cp:coreProperties>`;
|
|
834
942
|
}
|
|
835
943
|
|
|
836
|
-
function appXml(slideCount) {
|
|
837
|
-
return `${XML}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>MarkdStage</Application><PresentationFormat>On-screen Show (16:9)</PresentationFormat><Slides>${slideCount}</Slides><Notes
|
|
944
|
+
function appXml(slideCount, notesCount) {
|
|
945
|
+
return `${XML}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>MarkdStage</Application><PresentationFormat>On-screen Show (16:9)</PresentationFormat><Slides>${slideCount}</Slides><Notes>${notesCount}</Notes><HiddenSlides>0</HiddenSlides><MMClips>0</MMClips><ScaleCrop>false</ScaleCrop><Company/><LinksUpToDate>false</LinksUpToDate><SharedDoc>false</SharedDoc><HyperlinksChanged>false</HyperlinksChanged><AppVersion>1.0</AppVersion></Properties>`;
|
|
838
946
|
}
|
|
839
947
|
|
|
840
948
|
function themeXml() {
|
|
841
|
-
return `${XML}<a:theme xmlns:a="${NS_A}" name="MarkdStage"><a:themeElements><a:clrScheme name="MarkdStage"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F1F1F"/></a:dk2><a:lt2><a:srgbClr val="F2F2F2"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="MarkdStage"><a:majorFont><a:latin typeface="Aptos Display"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Aptos"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="MarkdStage"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="accent1"/></a:solidFill><a:solidFill><a:schemeClr val="accent2"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="28575"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:solidFill><a:schemeClr val="lt2"/></a:solidFill><a:solidFill><a:schemeClr val="dk1"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>`;
|
|
949
|
+
return `${XML}<a:theme xmlns:a="${NS_A}" name="MarkdStage"><a:themeElements><a:clrScheme name="MarkdStage"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F1F1F"/></a:dk2><a:lt2><a:srgbClr val="F2F2F2"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="MarkdStage"><a:majorFont><a:latin typeface="Aptos Display"/><a:ea typeface="${JAPANESE_FONT_FACE}"/><a:cs typeface=""/><a:font script="Jpan" typeface="${JAPANESE_FONT_FACE}"/></a:majorFont><a:minorFont><a:latin typeface="Aptos"/><a:ea typeface="${JAPANESE_FONT_FACE}"/><a:cs typeface=""/><a:font script="Jpan" typeface="${JAPANESE_FONT_FACE}"/></a:minorFont></a:fontScheme><a:fmtScheme name="MarkdStage"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="accent1"/></a:solidFill><a:solidFill><a:schemeClr val="accent2"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="28575"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:solidFill><a:schemeClr val="lt2"/></a:solidFill><a:solidFill><a:schemeClr val="dk1"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>`;
|
|
842
950
|
}
|
|
843
951
|
|
|
844
952
|
function slideMasterXml() {
|
|
@@ -849,6 +957,97 @@ function slideLayoutXml() {
|
|
|
849
957
|
return `${XML}<p:sldLayout xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}" type="blank" preserve="1"><p:cSld name="Blank"><p:spTree>${baseShapeTree()}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>`;
|
|
850
958
|
}
|
|
851
959
|
|
|
960
|
+
function notesPlaceholderXml({ id, name, type, idx, x, y, cx, cy, paragraphs = "" }) {
|
|
961
|
+
return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${xmlEscape(name)}"/><p:cNvSpPr txBox="1"/><p:nvPr><p:ph type="${type}" idx="${idx}"/></p:nvPr></p:nvSpPr><p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/>${paragraphs || "<a:p/>"}</p:txBody></p:sp>`;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function notesParagraphsXml(notes) {
|
|
965
|
+
return notes
|
|
966
|
+
.split("\n")
|
|
967
|
+
.map((line) => {
|
|
968
|
+
if (!line) {
|
|
969
|
+
return `<a:p><a:endParaRPr lang="en-US"><a:ea typeface="${JAPANESE_FONT_FACE}"/></a:endParaRPr></a:p>`;
|
|
970
|
+
}
|
|
971
|
+
const preserve = /^\s|\s$|\s{2}/.test(line) ? ' xml:space="preserve"' : "";
|
|
972
|
+
return `<a:p><a:r><a:rPr lang="en-US" dirty="0"><a:ea typeface="${JAPANESE_FONT_FACE}"/></a:rPr><a:t${preserve}>${xmlEscape(line)}</a:t></a:r><a:endParaRPr lang="en-US"><a:ea typeface="${JAPANESE_FONT_FACE}"/></a:endParaRPr></a:p>`;
|
|
973
|
+
})
|
|
974
|
+
.join("");
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function notesMasterXml() {
|
|
978
|
+
const slideImage = notesPlaceholderXml({
|
|
979
|
+
id: 2,
|
|
980
|
+
name: "Slide Image Placeholder 1",
|
|
981
|
+
type: "sldImg",
|
|
982
|
+
idx: 1,
|
|
983
|
+
x: 1143000,
|
|
984
|
+
y: 685800,
|
|
985
|
+
cx: 4572000,
|
|
986
|
+
cy: 3429000,
|
|
987
|
+
});
|
|
988
|
+
const body = notesPlaceholderXml({
|
|
989
|
+
id: 3,
|
|
990
|
+
name: "Notes Placeholder 2",
|
|
991
|
+
type: "body",
|
|
992
|
+
idx: 2,
|
|
993
|
+
x: 685800,
|
|
994
|
+
y: 4343400,
|
|
995
|
+
cx: 5486400,
|
|
996
|
+
cy: 4114800,
|
|
997
|
+
});
|
|
998
|
+
return `${XML}<p:notesMaster xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:cSld name="Notes Master"><p:spTree>${baseShapeTree()}${slideImage}${body}</p:spTree></p:cSld><p:clrMap accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" bg1="lt1" bg2="lt2" folHlink="folHlink" hlink="hlink" tx1="dk1" tx2="dk2"/><p:notesStyle><a:lvl1pPr marL="0" algn="l"><a:defRPr sz="1200"><a:latin typeface="Aptos"/><a:ea typeface="${JAPANESE_FONT_FACE}"/></a:defRPr></a:lvl1pPr></p:notesStyle></p:notesMaster>`;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function notesSlideXml(notes) {
|
|
1002
|
+
const slideImage = notesPlaceholderXml({
|
|
1003
|
+
id: 2,
|
|
1004
|
+
name: "Slide Image Placeholder 1",
|
|
1005
|
+
type: "sldImg",
|
|
1006
|
+
idx: 1,
|
|
1007
|
+
x: 1143000,
|
|
1008
|
+
y: 685800,
|
|
1009
|
+
cx: 4572000,
|
|
1010
|
+
cy: 3429000,
|
|
1011
|
+
});
|
|
1012
|
+
const body = notesPlaceholderXml({
|
|
1013
|
+
id: 3,
|
|
1014
|
+
name: "Notes Placeholder 2",
|
|
1015
|
+
type: "body",
|
|
1016
|
+
idx: 2,
|
|
1017
|
+
x: 685800,
|
|
1018
|
+
y: 4343400,
|
|
1019
|
+
cx: 5486400,
|
|
1020
|
+
cy: 4114800,
|
|
1021
|
+
paragraphs: notesParagraphsXml(notes),
|
|
1022
|
+
});
|
|
1023
|
+
return `${XML}<p:notes xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:cSld><p:spTree>${baseShapeTree()}${slideImage}${body}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>`;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function notesSlideRelsXml(slideIndex) {
|
|
1027
|
+
return relationshipsXml([
|
|
1028
|
+
{
|
|
1029
|
+
id: "rId1",
|
|
1030
|
+
type: REL.notesMaster,
|
|
1031
|
+
target: "../notesMasters/notesMaster1.xml",
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
id: "rId2",
|
|
1035
|
+
type: REL.slide,
|
|
1036
|
+
target: `../slides/slide${slideIndex + 1}.xml`,
|
|
1037
|
+
},
|
|
1038
|
+
]);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function normalizedNotes(slide, slideIndex) {
|
|
1042
|
+
const path = `slides[${slideIndex}]`;
|
|
1043
|
+
if (!slide || typeof slide !== "object" || Array.isArray(slide)) {
|
|
1044
|
+
fail(`${path} must be an object`);
|
|
1045
|
+
}
|
|
1046
|
+
if (slide.notes === undefined || slide.notes === null || slide.notes === "") return "";
|
|
1047
|
+
if (typeof slide.notes !== "string") fail(`${path}.notes must be a string`);
|
|
1048
|
+
return slide.notes.replace(/\r\n?/g, "\n").trim();
|
|
1049
|
+
}
|
|
1050
|
+
|
|
852
1051
|
function crc32(data) {
|
|
853
1052
|
let crc = 0xffffffff;
|
|
854
1053
|
for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
@@ -993,11 +1192,25 @@ export function buildPptxPackage({ title = "Presentation", slides, assets = [] }
|
|
|
993
1192
|
}
|
|
994
1193
|
if (slides.length > 0x7ffffeff) fail("slides contains too many items");
|
|
995
1194
|
const normalizedAssets = normalizeAssets(assets);
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
1195
|
+
const notesSlides = [];
|
|
1196
|
+
const builtSlides = slides.map((slide, index) => {
|
|
1197
|
+
const notes = normalizedNotes(slide, index);
|
|
1198
|
+
const notesSlideNumber = notes ? notesSlides.length + 1 : null;
|
|
1199
|
+
const built = buildSlide(slide, index, normalizedAssets, notesSlideNumber);
|
|
1200
|
+
if (notes) {
|
|
1201
|
+
notesSlides.push({
|
|
1202
|
+
notes,
|
|
1203
|
+
slideIndex: index,
|
|
1204
|
+
number: notesSlideNumber,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
return built;
|
|
1208
|
+
});
|
|
999
1209
|
const entries = [
|
|
1000
|
-
{
|
|
1210
|
+
{
|
|
1211
|
+
name: "[Content_Types].xml",
|
|
1212
|
+
data: contentTypesXml(slides.length, normalizedAssets, notesSlides.length),
|
|
1213
|
+
},
|
|
1001
1214
|
{
|
|
1002
1215
|
name: "_rels/.rels",
|
|
1003
1216
|
data: relationshipsXml([
|
|
@@ -1007,11 +1220,14 @@ export function buildPptxPackage({ title = "Presentation", slides, assets = [] }
|
|
|
1007
1220
|
]),
|
|
1008
1221
|
},
|
|
1009
1222
|
{ name: "docProps/core.xml", data: coreXml(title) },
|
|
1010
|
-
{ name: "docProps/app.xml", data: appXml(slides.length) },
|
|
1011
|
-
{
|
|
1223
|
+
{ name: "docProps/app.xml", data: appXml(slides.length, notesSlides.length) },
|
|
1224
|
+
{
|
|
1225
|
+
name: "ppt/presentation.xml",
|
|
1226
|
+
data: presentationXml(slides.length, notesSlides.length),
|
|
1227
|
+
},
|
|
1012
1228
|
{
|
|
1013
1229
|
name: "ppt/_rels/presentation.xml.rels",
|
|
1014
|
-
data: presentationRelsXml(slides.length),
|
|
1230
|
+
data: presentationRelsXml(slides.length, notesSlides.length),
|
|
1015
1231
|
},
|
|
1016
1232
|
{ name: "ppt/theme/theme1.xml", data: themeXml() },
|
|
1017
1233
|
{ name: "ppt/slideMasters/slideMaster1.xml", data: slideMasterXml() },
|
|
@@ -1037,6 +1253,18 @@ export function buildPptxPackage({ title = "Presentation", slides, assets = [] }
|
|
|
1037
1253
|
},
|
|
1038
1254
|
]),
|
|
1039
1255
|
},
|
|
1256
|
+
...(notesSlides.length
|
|
1257
|
+
? [
|
|
1258
|
+
{ name: "ppt/theme/theme2.xml", data: themeXml() },
|
|
1259
|
+
{ name: "ppt/notesMasters/notesMaster1.xml", data: notesMasterXml() },
|
|
1260
|
+
{
|
|
1261
|
+
name: "ppt/notesMasters/_rels/notesMaster1.xml.rels",
|
|
1262
|
+
data: relationshipsXml([
|
|
1263
|
+
{ id: "rId1", type: REL.theme, target: "../theme/theme2.xml" },
|
|
1264
|
+
]),
|
|
1265
|
+
},
|
|
1266
|
+
]
|
|
1267
|
+
: []),
|
|
1040
1268
|
...builtSlides.flatMap((slide, index) => [
|
|
1041
1269
|
{ name: `ppt/slides/slide${index + 1}.xml`, data: slide.xml },
|
|
1042
1270
|
{
|
|
@@ -1044,6 +1272,16 @@ export function buildPptxPackage({ title = "Presentation", slides, assets = [] }
|
|
|
1044
1272
|
data: slide.rels,
|
|
1045
1273
|
},
|
|
1046
1274
|
]),
|
|
1275
|
+
...notesSlides.flatMap((notesSlide) => [
|
|
1276
|
+
{
|
|
1277
|
+
name: `ppt/notesSlides/notesSlide${notesSlide.number}.xml`,
|
|
1278
|
+
data: notesSlideXml(notesSlide.notes),
|
|
1279
|
+
},
|
|
1280
|
+
{
|
|
1281
|
+
name: `ppt/notesSlides/_rels/notesSlide${notesSlide.number}.xml.rels`,
|
|
1282
|
+
data: notesSlideRelsXml(notesSlide.slideIndex),
|
|
1283
|
+
},
|
|
1284
|
+
]),
|
|
1047
1285
|
...[...normalizedAssets.values()].map((asset) => ({
|
|
1048
1286
|
name: asset.mediaPath,
|
|
1049
1287
|
data: asset.data,
|
|
@@ -1066,6 +1304,9 @@ export function inspectPptxPackage(buffer) {
|
|
|
1066
1304
|
const number = (name) => Number(/\d+/.exec(name)[0]);
|
|
1067
1305
|
return number(left) - number(right);
|
|
1068
1306
|
});
|
|
1307
|
+
const notesNames = [...files.keys()].filter((name) =>
|
|
1308
|
+
/^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(name),
|
|
1309
|
+
);
|
|
1069
1310
|
const core = files.get("docProps/core.xml")?.data.toString("utf8") || "";
|
|
1070
1311
|
const title = /<dc:title>([\s\S]*?)<\/dc:title>/.exec(core);
|
|
1071
1312
|
return {
|
|
@@ -1077,6 +1318,7 @@ export function inspectPptxPackage(buffer) {
|
|
|
1077
1318
|
crc32: entry.crc32,
|
|
1078
1319
|
})),
|
|
1079
1320
|
slideCount: slideNames.length,
|
|
1321
|
+
notesCount: notesNames.length,
|
|
1080
1322
|
mediaCount: [...files.keys()].filter((name) => name.startsWith("ppt/media/"))
|
|
1081
1323
|
.length,
|
|
1082
1324
|
dimensions: {
|
package/src/cli.mjs
CHANGED
|
@@ -149,6 +149,7 @@ function usage(command) {
|
|
|
149
149
|
"Usage: markdstage export <file.md> [--output slides.pdf|slides.pptx]",
|
|
150
150
|
"",
|
|
151
151
|
"Produces the same 16:9 PDF or hybrid editable PowerPoint as the MarkdStage canvas.",
|
|
152
|
+
"PowerPoint output includes speaker-note Markdown as readable plain text notes.",
|
|
152
153
|
"The output extension selects the format; omitting --output keeps PDF as the default.",
|
|
153
154
|
],
|
|
154
155
|
guide: [
|