@markdstage/markdstage 2.4.1 → 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 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.4.1",
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. Notes are absent from
77
- regular slides, the external presenter, and PDF output.
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. AI may call
99
- `export_pptx` with another workspace-confined `.pptx` path.
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 body = stripSpeakerNotes(directive.body);
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
- ...(element.matches("h1, h2, .slide-title") && renderedTextLineCount(element) === 1
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
  ) {
@@ -17,6 +17,7 @@ const NS_R =
17
17
  const NS_REL =
18
18
  "http://schemas.openxmlformats.org/package/2006/relationships";
19
19
  const JAPANESE_FONT_FACE = "Yu Gothic";
20
+ const HUNDREDTH_POINTS_PER_PIXEL = 75;
20
21
 
21
22
  const REL = {
22
23
  officeDocument: `${NS_R}/officeDocument`,
@@ -28,6 +29,8 @@ const REL = {
28
29
  theme: `${NS_R}/theme`,
29
30
  image: `${NS_R}/image`,
30
31
  hyperlink: `${NS_R}/hyperlink`,
32
+ notesSlide: `${NS_R}/notesSlide`,
33
+ notesMaster: `${NS_R}/notesMaster`,
31
34
  };
32
35
 
33
36
  const CONTENT_TYPES = {
@@ -50,8 +53,21 @@ function fail(message) {
50
53
  throw new TypeError(`Invalid PowerPoint model: ${message}`);
51
54
  }
52
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
+
53
67
  function xmlEscape(value) {
54
- return String(value)
68
+ return [...String(value)]
69
+ .map((character) => (isXmlCodePoint(character.codePointAt(0)) ? character : "\uFFFD"))
70
+ .join("")
55
71
  .replaceAll("&", "&amp;")
56
72
  .replaceAll("<", "&lt;")
57
73
  .replaceAll(">", "&gt;")
@@ -334,7 +350,26 @@ function normalizeText(value, path) {
334
350
  return value;
335
351
  }
336
352
 
337
- function paragraphXml(paragraph, path, relationships) {
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) {
338
373
  if (!paragraph || typeof paragraph !== "object" || Array.isArray(paragraph)) {
339
374
  fail(`${path} must be an object`);
340
375
  }
@@ -360,12 +395,21 @@ function paragraphXml(paragraph, path, relationships) {
360
395
  : paragraph.bullet.character || "•";
361
396
  bullet = `<a:buChar char="${xmlEscape(character)}"/>`;
362
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}` : "";
363
407
  const runs = paragraph.runs
364
408
  .map((run, index) =>
365
409
  runXml(run, `${path}.runs[${index}]`, relationships),
366
410
  )
367
411
  .join("");
368
- return `<a:p><a:pPr algn="${align}" lvl="${level}">${bullet}</a:pPr>${runs}<a:endParaRPr lang="en-US"><a:ea typeface="${JAPANESE_FONT_FACE}"/></a:endParaRPr></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>`;
369
413
  }
370
414
 
371
415
  function runXml(run, path, relationships) {
@@ -376,6 +420,7 @@ function runXml(run, path, relationships) {
376
420
  const size = fontSizeOf(run, path);
377
421
  const attributes = [
378
422
  'lang="en-US"',
423
+ 'noProof="1"',
379
424
  `sz="${size}"`,
380
425
  run.bold ? 'b="1"' : "",
381
426
  run.italic ? 'i="1"' : "",
@@ -448,11 +493,17 @@ function textBodyXml(
448
493
  tag = "p:txBody",
449
494
  bodyOptions = {},
450
495
  bodyPath = path,
496
+ leftMarginPx = 0,
451
497
  ) {
452
498
  const text = normalizeText(value, path);
453
499
  const paragraphs = text.paragraphs
454
500
  .map((paragraph, index) =>
455
- paragraphXml(paragraph, `${path}.paragraphs[${index}]`, relationships),
501
+ paragraphXml(
502
+ paragraph,
503
+ `${path}.paragraphs[${index}]`,
504
+ relationships,
505
+ leftMarginPx,
506
+ ),
456
507
  )
457
508
  .join("");
458
509
  return `<${tag}>${textBodyPropertiesXml(bodyOptions, bodyPath)}<a:lstStyle/>${paragraphs}</${tag}>`;
@@ -465,12 +516,35 @@ function shapeBase(id, name, bounds, properties, text = "") {
465
516
  function textShapeXml(element, path, id, relationships) {
466
517
  const bounds = boundsOf(element, path);
467
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;
468
534
  return shapeBase(
469
535
  id,
470
536
  `Text ${id}`,
471
- bounds,
537
+ adjustedBounds,
472
538
  '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>',
473
- textBodyXml(text, `${path}`, relationships, "p:txBody", element),
539
+ textBodyXml(
540
+ text,
541
+ `${path}`,
542
+ relationships,
543
+ "p:txBody",
544
+ element,
545
+ path,
546
+ bulletInsetPx,
547
+ ),
474
548
  );
475
549
  }
476
550
 
@@ -665,7 +739,7 @@ function connectorXml(element, path, nextId, relationships) {
665
739
  return shapes.join("");
666
740
  }
667
741
 
668
- function relationshipRegistry() {
742
+ function relationshipRegistry(notesSlideNumber = null) {
669
743
  const entries = [
670
744
  {
671
745
  id: "rId1",
@@ -674,6 +748,14 @@ function relationshipRegistry() {
674
748
  external: false,
675
749
  },
676
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
+ }
677
759
  const images = new Map();
678
760
  const hyperlinks = new Map();
679
761
  const add = (type, target, external) => {
@@ -707,13 +789,13 @@ function baseShapeTree() {
707
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>';
708
790
  }
709
791
 
710
- function buildSlide(slide, slideIndex, assets) {
792
+ function buildSlide(slide, slideIndex, assets, notesSlideNumber = null) {
711
793
  const path = `slides[${slideIndex}]`;
712
794
  if (!slide || typeof slide !== "object" || Array.isArray(slide)) {
713
795
  fail(`${path} must be an object`);
714
796
  }
715
797
  if (!Array.isArray(slide.elements)) fail(`${path}.elements must be an array`);
716
- const relationships = relationshipRegistry();
798
+ const relationships = relationshipRegistry(notesSlideNumber);
717
799
  let shapeId = 2;
718
800
  const nextId = () => shapeId++;
719
801
  const shapes = [];
@@ -787,7 +869,7 @@ function relationshipsXml(entries) {
787
869
  .join("")}</Relationships>`;
788
870
  }
789
871
 
790
- function contentTypesXml(slideCount, assets) {
872
+ function contentTypesXml(slideCount, assets, notesCount) {
791
873
  const imageTypes = new Map();
792
874
  for (const asset of assets.values()) {
793
875
  imageTypes.set(asset.extension, asset.contentType);
@@ -803,19 +885,36 @@ function contentTypesXml(slideCount, assets) {
803
885
  (_, index) =>
804
886
  `<Override PartName="/ppt/slides/slide${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`,
805
887
  ).join("");
806
- 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"/>${slides}</Types>`;
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>`;
807
902
  }
808
903
 
809
- function presentationXml(slideCount) {
904
+ function presentationXml(slideCount, notesCount) {
810
905
  const slides = Array.from(
811
906
  { length: slideCount },
812
907
  (_, index) =>
813
908
  `<p:sldId id="${256 + index}" r:id="rId${index + 2}"/>`,
814
909
  ).join("");
815
- 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><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>`;
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>`;
816
915
  }
817
916
 
818
- function presentationRelsXml(slideCount) {
917
+ function presentationRelsXml(slideCount, notesCount) {
819
918
  const entries = [
820
919
  {
821
920
  id: "rId1",
@@ -828,6 +927,13 @@ function presentationRelsXml(slideCount) {
828
927
  target: `slides/slide${index + 1}.xml`,
829
928
  })),
830
929
  ];
930
+ if (notesCount > 0) {
931
+ entries.push({
932
+ id: `rId${slideCount + 2}`,
933
+ type: REL.notesMaster,
934
+ target: "notesMasters/notesMaster1.xml",
935
+ });
936
+ }
831
937
  return relationshipsXml(entries);
832
938
  }
833
939
 
@@ -835,8 +941,8 @@ function coreXml(title) {
835
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>`;
836
942
  }
837
943
 
838
- function appXml(slideCount) {
839
- 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>0</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>`;
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>`;
840
946
  }
841
947
 
842
948
  function themeXml() {
@@ -851,6 +957,97 @@ function slideLayoutXml() {
851
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>`;
852
958
  }
853
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
+
854
1051
  function crc32(data) {
855
1052
  let crc = 0xffffffff;
856
1053
  for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
@@ -995,11 +1192,25 @@ export function buildPptxPackage({ title = "Presentation", slides, assets = [] }
995
1192
  }
996
1193
  if (slides.length > 0x7ffffeff) fail("slides contains too many items");
997
1194
  const normalizedAssets = normalizeAssets(assets);
998
- const builtSlides = slides.map((slide, index) =>
999
- buildSlide(slide, index, normalizedAssets),
1000
- );
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
+ });
1001
1209
  const entries = [
1002
- { name: "[Content_Types].xml", data: contentTypesXml(slides.length, normalizedAssets) },
1210
+ {
1211
+ name: "[Content_Types].xml",
1212
+ data: contentTypesXml(slides.length, normalizedAssets, notesSlides.length),
1213
+ },
1003
1214
  {
1004
1215
  name: "_rels/.rels",
1005
1216
  data: relationshipsXml([
@@ -1009,11 +1220,14 @@ export function buildPptxPackage({ title = "Presentation", slides, assets = [] }
1009
1220
  ]),
1010
1221
  },
1011
1222
  { name: "docProps/core.xml", data: coreXml(title) },
1012
- { name: "docProps/app.xml", data: appXml(slides.length) },
1013
- { name: "ppt/presentation.xml", data: presentationXml(slides.length) },
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
+ },
1014
1228
  {
1015
1229
  name: "ppt/_rels/presentation.xml.rels",
1016
- data: presentationRelsXml(slides.length),
1230
+ data: presentationRelsXml(slides.length, notesSlides.length),
1017
1231
  },
1018
1232
  { name: "ppt/theme/theme1.xml", data: themeXml() },
1019
1233
  { name: "ppt/slideMasters/slideMaster1.xml", data: slideMasterXml() },
@@ -1039,6 +1253,18 @@ export function buildPptxPackage({ title = "Presentation", slides, assets = [] }
1039
1253
  },
1040
1254
  ]),
1041
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
+ : []),
1042
1268
  ...builtSlides.flatMap((slide, index) => [
1043
1269
  { name: `ppt/slides/slide${index + 1}.xml`, data: slide.xml },
1044
1270
  {
@@ -1046,6 +1272,16 @@ export function buildPptxPackage({ title = "Presentation", slides, assets = [] }
1046
1272
  data: slide.rels,
1047
1273
  },
1048
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
+ ]),
1049
1285
  ...[...normalizedAssets.values()].map((asset) => ({
1050
1286
  name: asset.mediaPath,
1051
1287
  data: asset.data,
@@ -1068,6 +1304,9 @@ export function inspectPptxPackage(buffer) {
1068
1304
  const number = (name) => Number(/\d+/.exec(name)[0]);
1069
1305
  return number(left) - number(right);
1070
1306
  });
1307
+ const notesNames = [...files.keys()].filter((name) =>
1308
+ /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(name),
1309
+ );
1071
1310
  const core = files.get("docProps/core.xml")?.data.toString("utf8") || "";
1072
1311
  const title = /<dc:title>([\s\S]*?)<\/dc:title>/.exec(core);
1073
1312
  return {
@@ -1079,6 +1318,7 @@ export function inspectPptxPackage(buffer) {
1079
1318
  crc32: entry.crc32,
1080
1319
  })),
1081
1320
  slideCount: slideNames.length,
1321
+ notesCount: notesNames.length,
1082
1322
  mediaCount: [...files.keys()].filter((name) => name.startsWith("ppt/media/"))
1083
1323
  .length,
1084
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: [