@markdstage/markdstage 2.4.1 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/package.json +1 -1
- package/shared/README.md +8 -5
- package/shared/renderer/renderer.js +93 -10
- package/shared/renderer/speaker-notes.mjs +252 -0
- package/shared/runtime/output.mjs +8 -0
- package/shared/runtime/pptx-package.mjs +263 -23
- package/src/cli.mjs +1 -0
package/README.md
CHANGED
|
@@ -21,6 +21,13 @@ npx @markdstage/markdstage present slides.md
|
|
|
21
21
|
npm install --global @markdstage/markdstage
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
For offline installation, download the versioned `.tgz` asset from the
|
|
25
|
+
[GitHub Release](https://github.com/runceel/markdstage/releases) and install it locally:
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
npm install --global .\markdstage-markdstage-<version>.tgz
|
|
29
|
+
```
|
|
30
|
+
|
|
24
31
|
## Commands
|
|
25
32
|
|
|
26
33
|
```console
|
|
@@ -43,7 +50,7 @@ markdstage skill install --target claude
|
|
|
43
50
|
| `validate` | Checks deck structure, Architecture DSL blocks, themes, and theme paths. |
|
|
44
51
|
| `inspect` | Reports the same compact 1280x720 clipping diagnostics as the canvas `inspect_layout` action. `--slide <n>` limits it to one page, `--all` includes slides that fit, `--fail-on-issues` exits with code 5. |
|
|
45
52
|
| `capture` | Writes 1280x720 PNG files. Without `--pages` only the slides reported as clipped are captured. |
|
|
46
|
-
| `export` | Produces the same 16:9 PDF or hybrid editable PowerPoint as the canvas Extension. The `--output` extension selects the format; PDF remains the default. |
|
|
53
|
+
| `export` | Produces the same 16:9 PDF or hybrid editable PowerPoint as the canvas Extension. PowerPoint output includes speaker-note Markdown as readable plain text notes. The `--output` extension selects the format; PDF remains the default. |
|
|
47
54
|
| `guide` | Prints the canonical `markdstage_guide` topics. |
|
|
48
55
|
| `skill` | Installs or checks the portable Agent Skills for Codex (`.agents/skills/markdstage/`), Claude Code (`.claude/skills/markdstage/`), and GitHub Copilot (`.github/skills/markdstage/`). Locally modified files are never overwritten without `--force`. |
|
|
49
56
|
| `help` | Shows the overview, or the help for one command. `markdstage help <command>` prints the same text as `markdstage <command> --help`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markdstage/markdstage",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Present, validate, inspect, capture, and export MarkdStage Markdown decks from the command line — no Copilot canvas required.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "runceel",
|
package/shared/README.md
CHANGED
|
@@ -73,8 +73,9 @@ The themed slide is displayed and updates automatically
|
|
|
73
73
|
`auto` measures standard slides without code, tables, images, or Mermaid and
|
|
74
74
|
enlarges only when ample space remains.
|
|
75
75
|
- Put **speaker notes** in top-level HTML comments on each slide. Presenter view
|
|
76
|
-
renders notes as Markdown and follows navigation.
|
|
77
|
-
regular slides, the external
|
|
76
|
+
renders notes as Markdown and follows navigation. PowerPoint export includes
|
|
77
|
+
readable plain-text notes. Notes are absent from regular slides, the external
|
|
78
|
+
presenter, and PDF output.
|
|
78
79
|
- The **canvas renderer** owns the compact navigation controls, shape editing,
|
|
79
80
|
the slide list, and current position. **More controls > Shape editing**
|
|
80
81
|
toggles the same placement mode as `edit_architecture`. For Markdown loaded
|
|
@@ -95,8 +96,10 @@ The themed slide is displayed and updates automatically
|
|
|
95
96
|
DSL objects as native PowerPoint content. Architecture nodes, groups, and
|
|
96
97
|
connector-label pills are visible AutoShapes with integrated text; icons use a
|
|
97
98
|
transparent foreground picture layer. Mermaid and unsupported styling stay
|
|
98
|
-
visible as background artwork and are listed in the export report.
|
|
99
|
-
|
|
99
|
+
visible as background artwork and are listed in the export report. Speaker
|
|
100
|
+
notes are converted from Markdown to readable plain text in the corresponding
|
|
101
|
+
PowerPoint notes pane. AI may call `export_pptx` with another
|
|
102
|
+
workspace-confined `.pptx` path.
|
|
100
103
|
- Use **More controls > Output preview** to letterbox the current slide inside
|
|
101
104
|
the canvas with
|
|
102
105
|
the same fixed 1280×720 typography, spacing, diagram limits, and clipping used
|
|
@@ -945,7 +948,7 @@ fails with `invalid_input`; pass the complete `slides` array or call
|
|
|
945
948
|
| `inspect_layout` | `{ index?: number, includeFits?: boolean }`. Render the registered in-memory PDF snapshot with the fixed 1280×720 output layout; this does not inspect the source file on disk. Omit `index` for one preferred whole-deck inspection. Serialize targeted calls because PDF, layout, and PNG jobs are exclusive. By default, return only clipped pages; `includeFits` includes successful pages. Returns dimensions, issue counts, overflow measurements, nested scroll containers, and a bounded list of element hints. Requires Edge, Chrome, or Chromium. |
|
|
946
949
|
| `capture_slides` | `{ indexes?: number[], outputDirectory?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Generate PDF-equivalent 1280×720 PNGs for at most 10 zero-based indexes. When `indexes` is omitted, inspect the deck and capture only clipped pages. Paths stay inside the workspace; results contain paths and layout summaries, not image bytes. Requires Edge, Chrome, or Chromium. |
|
|
947
950
|
| `export_pdf` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export one 16:9 page per slide. Relative paths use workspace root; default is `markdstage.pdf`. Theme affects PDF only. Reject paths outside workspace and non-`.pdf` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes }`. Requires Edge, Chrome, or Chromium. |
|
|
948
|
-
| `export_pptx` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export a hybrid editable 16:9 PowerPoint deck. Supported text, lists, links, tables, raster images, and Architecture DSL objects remain native. Architecture nodes, groups, and connector-label pills are visible AutoShapes with integrated text; icons are foreground pictures. Mermaid and unsupported visuals become reported background fallbacks. Relative paths use workspace root; default is `markdstage.pptx`. Reject paths outside workspace and non-`.pptx` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes, format, fallbackCount, fallbacks }`. Requires Edge, Chrome, or Chromium. |
|
|
951
|
+
| `export_pptx` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export a hybrid editable 16:9 PowerPoint deck. Supported text, lists, links, tables, raster images, and Architecture DSL objects remain native. Speaker-note Markdown is exported as readable plain text in the corresponding PowerPoint notes pane. Architecture nodes, groups, and connector-label pills are visible AutoShapes with integrated text; icons are foreground pictures. Mermaid and unsupported visuals become reported background fallbacks. Relative paths use workspace root; default is `markdstage.pptx`. Reject paths outside workspace and non-`.pptx` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes, format, fallbackCount, fallbacks }`. Requires Edge, Chrome, or Chromium. |
|
|
949
952
|
| `edit_architecture` | `{ enabled: boolean }`. Toggle placement editing. Imported decks also write to the source fence; direct decks write to canvas state. Presenter/print omit UI. Mode is not persisted and `reset` disables it. Returns `{ ok, enabled, version }`. |
|
|
950
953
|
| `reset` | No input. Clear deck/slide state, disable editing, and return to the waiting view. |
|
|
951
954
|
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from "./theme.mjs";
|
|
8
8
|
import {
|
|
9
9
|
extractSpeakerNotes,
|
|
10
|
+
speakerNotesToPlainText,
|
|
11
|
+
splitSpeakerNotes,
|
|
10
12
|
stripSpeakerNotes,
|
|
11
13
|
} from "./speaker-notes.mjs";
|
|
12
14
|
import { splitImportPath } from "./import-path.mjs";
|
|
@@ -604,7 +606,8 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
|
|
|
604
606
|
const md = placeholder ? PLACEHOLDER : markdown;
|
|
605
607
|
const { meta, body: rawBody } = splitFrontMatter(md);
|
|
606
608
|
const directive = extractSlideSizeDirective(rawBody);
|
|
607
|
-
const
|
|
609
|
+
const speakerNotes = splitSpeakerNotes(directive.body);
|
|
610
|
+
const body = speakerNotes.markdown;
|
|
608
611
|
|
|
609
612
|
const layout = (meta.layout || "").toLowerCase();
|
|
610
613
|
const titleSlide = layout === "title";
|
|
@@ -771,6 +774,7 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
|
|
|
771
774
|
sectionSlide,
|
|
772
775
|
centerSlide,
|
|
773
776
|
backcoverSlide,
|
|
777
|
+
speakerNotes: speakerNotes.notes,
|
|
774
778
|
title: meta.title || meta.deck || "Slide",
|
|
775
779
|
};
|
|
776
780
|
}
|
|
@@ -936,6 +940,60 @@ function textContentBounds(element, deck) {
|
|
|
936
940
|
};
|
|
937
941
|
}
|
|
938
942
|
|
|
943
|
+
function textInsetsFor(element) {
|
|
944
|
+
const style = getComputedStyle(element);
|
|
945
|
+
const metric = (padding, border) => {
|
|
946
|
+
const paddingValue = Number.parseFloat(style[padding]);
|
|
947
|
+
const borderValue = Number.parseFloat(style[border]);
|
|
948
|
+
return roundedMetric(
|
|
949
|
+
Math.max(0, Number.isFinite(paddingValue) ? paddingValue : 0) +
|
|
950
|
+
Math.max(0, Number.isFinite(borderValue) ? borderValue : 0),
|
|
951
|
+
);
|
|
952
|
+
};
|
|
953
|
+
const insets = {
|
|
954
|
+
left: metric("paddingLeft", "borderLeftWidth"),
|
|
955
|
+
top: metric("paddingTop", "borderTopWidth"),
|
|
956
|
+
right: metric("paddingRight", "borderRightWidth"),
|
|
957
|
+
bottom: metric("paddingBottom", "borderBottomWidth"),
|
|
958
|
+
};
|
|
959
|
+
return Object.values(insets).some((value) => value > 0) ? insets : null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function singleLineTextLayout(element, deck, bounds, textInsets, alignment) {
|
|
963
|
+
const content = textContentBounds(element, deck);
|
|
964
|
+
const fontSize = Number.parseFloat(getComputedStyle(element).fontSize);
|
|
965
|
+
const widthBuffer = Math.max(2, (Number.isFinite(fontSize) ? fontSize : 0) * 0.5);
|
|
966
|
+
const fittedInsets = textInsets ? { ...textInsets } : null;
|
|
967
|
+
const requiredWidth = () =>
|
|
968
|
+
content.width +
|
|
969
|
+
(fittedInsets?.left || 0) +
|
|
970
|
+
(fittedInsets?.right || 0) +
|
|
971
|
+
widthBuffer;
|
|
972
|
+
const resolvedAlignment =
|
|
973
|
+
alignment || pptxAlignment(getComputedStyle(element).textAlign);
|
|
974
|
+
// Keep the leading CSS inset, but release a trailing inset when browser and
|
|
975
|
+
// PowerPoint font metrics would otherwise force a visually single line to wrap.
|
|
976
|
+
if (fittedInsets && requiredWidth() > bounds.width) {
|
|
977
|
+
if (resolvedAlignment === "right") fittedInsets.left = 0;
|
|
978
|
+
if (resolvedAlignment === "left") fittedInsets.right = 0;
|
|
979
|
+
}
|
|
980
|
+
const extraWidth = Math.max(0, requiredWidth() - bounds.width);
|
|
981
|
+
const offset =
|
|
982
|
+
resolvedAlignment === "right"
|
|
983
|
+
? extraWidth
|
|
984
|
+
: resolvedAlignment === "center"
|
|
985
|
+
? extraWidth / 2
|
|
986
|
+
: 0;
|
|
987
|
+
return {
|
|
988
|
+
bounds: {
|
|
989
|
+
...bounds,
|
|
990
|
+
x: roundedMetric(bounds.x - offset),
|
|
991
|
+
width: roundedMetric(bounds.width + extraWidth),
|
|
992
|
+
},
|
|
993
|
+
textInsets: fittedInsets,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
939
997
|
function rasterImageSupported(image) {
|
|
940
998
|
const source = image.currentSrc || image.getAttribute("src") || "";
|
|
941
999
|
if (["cover", "none"].includes(getComputedStyle(image).objectFit)) return false;
|
|
@@ -1024,12 +1082,22 @@ function paragraphFor(element, options = {}) {
|
|
|
1024
1082
|
function renderedTextLineCount(element) {
|
|
1025
1083
|
const range = document.createRange();
|
|
1026
1084
|
range.selectNodeContents(element);
|
|
1027
|
-
const
|
|
1085
|
+
const lines = [];
|
|
1028
1086
|
for (const rect of range.getClientRects()) {
|
|
1029
1087
|
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
1030
|
-
|
|
1088
|
+
const line = lines.find(
|
|
1089
|
+
(candidate) =>
|
|
1090
|
+
rect.top < candidate.bottom - 1 &&
|
|
1091
|
+
rect.bottom > candidate.top + 1,
|
|
1092
|
+
);
|
|
1093
|
+
if (line) {
|
|
1094
|
+
line.top = Math.min(line.top, rect.top);
|
|
1095
|
+
line.bottom = Math.max(line.bottom, rect.bottom);
|
|
1096
|
+
} else {
|
|
1097
|
+
lines.push({ top: rect.top, bottom: rect.bottom });
|
|
1098
|
+
}
|
|
1031
1099
|
}
|
|
1032
|
-
return Math.max(1,
|
|
1100
|
+
return Math.max(1, lines.length);
|
|
1033
1101
|
}
|
|
1034
1102
|
|
|
1035
1103
|
function unsupportedEffects(element) {
|
|
@@ -1583,17 +1651,30 @@ async function collectPptxSlide(slide, index) {
|
|
|
1583
1651
|
: undefined,
|
|
1584
1652
|
});
|
|
1585
1653
|
if (!paragraph.runs.some((run) => run.text.trim())) continue;
|
|
1654
|
+
const disableTextWrap = renderedTextLineCount(element) === 1;
|
|
1655
|
+
const textInsets = textInsetsFor(element);
|
|
1656
|
+
const baseBounds = element.classList.contains("kicker")
|
|
1657
|
+
? textContentBounds(element, deck)
|
|
1658
|
+
: relativeBounds(element, deck);
|
|
1659
|
+
const singleLineLayout = disableTextWrap
|
|
1660
|
+
? singleLineTextLayout(
|
|
1661
|
+
element,
|
|
1662
|
+
deck,
|
|
1663
|
+
baseBounds,
|
|
1664
|
+
textInsets,
|
|
1665
|
+
element.classList.contains("kicker") ? "left" : undefined,
|
|
1666
|
+
)
|
|
1667
|
+
: null;
|
|
1668
|
+
const bounds = singleLineLayout?.bounds || baseBounds;
|
|
1669
|
+
const fittedTextInsets = singleLineLayout?.textInsets || textInsets;
|
|
1586
1670
|
elements.push({
|
|
1587
1671
|
type: "text",
|
|
1588
1672
|
path: elementPath(element, deck),
|
|
1589
|
-
...
|
|
1590
|
-
? textContentBounds(element, deck)
|
|
1591
|
-
: relativeBounds(element, deck)),
|
|
1673
|
+
...bounds,
|
|
1592
1674
|
paragraphs: [paragraph],
|
|
1593
1675
|
opacity: Number(getComputedStyle(element).opacity) || 1,
|
|
1594
|
-
...(
|
|
1595
|
-
|
|
1596
|
-
: {}),
|
|
1676
|
+
...(fittedTextInsets ? { textInsets: fittedTextInsets } : {}),
|
|
1677
|
+
...(disableTextWrap ? { textWrap: "none" } : {}),
|
|
1597
1678
|
});
|
|
1598
1679
|
element.setAttribute("data-pptx-native", "text");
|
|
1599
1680
|
}
|
|
@@ -1749,6 +1830,7 @@ async function collectPptxSlide(slide, index) {
|
|
|
1749
1830
|
? "backcover"
|
|
1750
1831
|
: "standard";
|
|
1751
1832
|
const visibleTitle = deck.querySelector("h1, h2")?.textContent?.trim();
|
|
1833
|
+
const notes = speakerNotesToPlainText(slide.speakerNotes, window.marked, document);
|
|
1752
1834
|
return {
|
|
1753
1835
|
index,
|
|
1754
1836
|
layout,
|
|
@@ -1756,6 +1838,7 @@ async function collectPptxSlide(slide, index) {
|
|
|
1756
1838
|
title: visibleTitle || slide.title,
|
|
1757
1839
|
width: OUTPUT_WIDTH,
|
|
1758
1840
|
height: OUTPUT_HEIGHT,
|
|
1841
|
+
...(notes ? { notes } : {}),
|
|
1759
1842
|
elements,
|
|
1760
1843
|
fallbacks,
|
|
1761
1844
|
};
|
|
@@ -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("&", "&")
|
|
56
72
|
.replaceAll("<", "<")
|
|
57
73
|
.replaceAll(">", ">")
|
|
@@ -334,7 +350,26 @@ function normalizeText(value, path) {
|
|
|
334
350
|
return value;
|
|
335
351
|
}
|
|
336
352
|
|
|
337
|
-
function
|
|
353
|
+
function bulletTextOffsetPx(paragraph, path) {
|
|
354
|
+
if (
|
|
355
|
+
!paragraph ||
|
|
356
|
+
typeof paragraph !== "object" ||
|
|
357
|
+
Array.isArray(paragraph) ||
|
|
358
|
+
!paragraph.bullet ||
|
|
359
|
+
!Array.isArray(paragraph.runs) ||
|
|
360
|
+
paragraph.runs.length === 0
|
|
361
|
+
) {
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
const largestRunSize = Math.max(
|
|
365
|
+
...paragraph.runs.map((run, index) =>
|
|
366
|
+
fontSizeOf(run, `${path}.runs[${index}]`),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
return largestRunSize / HUNDREDTH_POINTS_PER_PIXEL;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function paragraphXml(paragraph, path, relationships, leftMarginPx = 0) {
|
|
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(
|
|
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
|
-
|
|
537
|
+
adjustedBounds,
|
|
472
538
|
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>',
|
|
473
|
-
textBodyXml(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
999
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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: [
|