@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 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.0",
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
  ) {
@@ -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("&", "&amp;")
55
72
  .replaceAll("<", "&lt;")
56
73
  .replaceAll(">", "&gt;")
@@ -333,7 +350,26 @@ function normalizeText(value, path) {
333
350
  return value;
334
351
  }
335
352
 
336
- 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) {
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(paragraph, `${path}.paragraphs[${index}]`, relationships),
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
- bounds,
537
+ adjustedBounds,
470
538
  '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>',
471
- 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
+ ),
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
- 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>`;
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
- 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>`;
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>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>`;
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 builtSlides = slides.map((slide, index) =>
997
- buildSlide(slide, index, normalizedAssets),
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
- { name: "[Content_Types].xml", data: contentTypesXml(slides.length, normalizedAssets) },
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
- { 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
+ },
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: [