@tycoworks/tycoslide 0.7.0 → 0.8.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 +6 -6
- package/SKILL.md +2 -1
- package/dist/cli.js +5 -94
- package/dist/engine/dom.d.ts +5 -0
- package/dist/engine/dom.js +19 -3
- package/dist/engine/fillers/filler.js +1 -4
- package/dist/engine/fillers/image.d.ts +46 -7
- package/dist/engine/fillers/image.js +78 -36
- package/dist/engine/fillers/table.d.ts +4 -3
- package/dist/engine/fillers/table.js +58 -9
- package/dist/engine/generate.d.ts +11 -1
- package/dist/engine/generate.js +84 -2
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/notes.d.ts +76 -0
- package/dist/engine/notes.js +313 -0
- package/dist/engine/types.d.ts +18 -15
- package/dist/engine/types.js +11 -7
- package/dist/index.d.ts +4 -4
- package/dist/index.js +7 -9
- package/dist/manifest.js +3 -5
- package/dist/markdown/deckCompiler.d.ts +5 -14
- package/dist/markdown/deckCompiler.js +43 -20
- package/dist/markdown/index.d.ts +3 -3
- package/dist/markdown/index.js +3 -3
- package/dist/markdown/resolvers/mermaid.js +19 -27
- package/dist/markdown/types.d.ts +20 -6
- package/dist/markdown/types.js +12 -1
- package/package.json +3 -2
- package/syntax.md +25 -5
package/dist/engine/generate.js
CHANGED
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { existsSync, rmSync } from "node:fs";
|
|
26
26
|
import { basename, dirname, resolve } from "node:path";
|
|
27
|
+
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
|
|
27
28
|
import { Automizer, modify } from "pptx-automizer";
|
|
28
29
|
import { FILLERS } from "./fillers/filler.js";
|
|
29
30
|
import { isImageFill } from "./fillers/image.js";
|
|
31
|
+
import { applyNotesToSlide, sweepOrphanNotes } from "./notes.js";
|
|
30
32
|
/**
|
|
31
33
|
* Generate a PPTX file from a deck definition and a theme configuration.
|
|
32
34
|
*
|
|
@@ -39,7 +41,7 @@ import { isImageFill } from "./fillers/image.js";
|
|
|
39
41
|
* via `FILLERS[slot.type]`.
|
|
40
42
|
* 4. Write the output PPTX.
|
|
41
43
|
*/
|
|
42
|
-
export async function generate(deck, config) {
|
|
44
|
+
export async function generate(deck, config, options = {}) {
|
|
43
45
|
const { layouts, rootDir, template, outputDir } = config;
|
|
44
46
|
const outFile = deck.output;
|
|
45
47
|
const outDir = outputDir ?? process.cwd();
|
|
@@ -120,10 +122,36 @@ export async function generate(deck, config) {
|
|
|
120
122
|
filler.fill(slide, slot, value, { layoutName: step.layout });
|
|
121
123
|
}
|
|
122
124
|
};
|
|
125
|
+
// Default: write authored notes and strip any template notes automizer clones
|
|
126
|
+
// onto slides. When true, no notes are written but inherited notes are still
|
|
127
|
+
// stripped — the deck is guaranteed notes-free.
|
|
128
|
+
const excludeNotes = options.excludeNotes ?? false;
|
|
123
129
|
for (const step of deck.steps) {
|
|
124
130
|
const layout = resolveLayout(step.layout);
|
|
125
|
-
pres.addSlide(sourceAlias, layout.slideNumber, (slide) =>
|
|
131
|
+
pres.addSlide(sourceAlias, layout.slideNumber, (slide) => {
|
|
132
|
+
fillSlide(slide, layout, step);
|
|
133
|
+
// In-band notes pass: automizer runs this during write() and hands us the
|
|
134
|
+
// OUTPUT archive (parent.targetArchive) and the real output slide number
|
|
135
|
+
// (parent.targetNumber), so notes map 1:1 with no slide-number mapping.
|
|
136
|
+
// Registered for EVERY slide — a slide without authored notes must still
|
|
137
|
+
// have any auto-copied template notes stripped.
|
|
138
|
+
slide.modify(async (_document, parent) => {
|
|
139
|
+
await applyNotesToSlide(toNotesArchive(parent.targetArchive), parent.targetNumber, step.notes, excludeNotes);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
126
142
|
}
|
|
143
|
+
// Sweep the template's orphaned notesSlides. This MUST be a presentation-level
|
|
144
|
+
// modify callback, not part of the per-slide pass: per-slide callbacks run
|
|
145
|
+
// during writeSlides(), BEFORE automizer truncates the template's original
|
|
146
|
+
// slides and renumbers the deck — at which point every template notesSlide
|
|
147
|
+
// still looks referenced by a not-yet-removed original slide. automizer runs
|
|
148
|
+
// presentation modify callbacks after truncation, when a notesSlide's
|
|
149
|
+
// referenced status is final. Runs regardless of excludeNotes — the leak
|
|
150
|
+
// exists either way. Routed through toNotesArchive so the buffered
|
|
151
|
+
// [Content_Types].xml write survives automizer's final buffer flush.
|
|
152
|
+
pres.modify(async (_xml, _index, archive) => {
|
|
153
|
+
await sweepOrphanNotes(toNotesArchive(archive));
|
|
154
|
+
});
|
|
127
155
|
await pres.write(outFile);
|
|
128
156
|
if (fillErrors.length > 0) {
|
|
129
157
|
// The output would be a broken deck — remove it so a failed build never
|
|
@@ -145,6 +173,60 @@ function describeValue(v) {
|
|
|
145
173
|
}
|
|
146
174
|
return typeof v;
|
|
147
175
|
}
|
|
176
|
+
// ── Notes archive adapter (automizer-buffer glue) ────────────────────────────
|
|
177
|
+
/** Installed pptx-automizer version this notes-buffer adapter is pinned to. */
|
|
178
|
+
const PPTX_AUTOMIZER_VERSION = "0.8.2";
|
|
179
|
+
/**
|
|
180
|
+
* Bridge pptx-automizer's OUTPUT archive (`parent.targetArchive`) to the minimal
|
|
181
|
+
* {@link NotesArchive} surface, cooperating with automizer's XML buffer.
|
|
182
|
+
*
|
|
183
|
+
* automizer caches every part it edits via `readXml`/`writeXml` — for a cloned
|
|
184
|
+
* slide's notes that means `slide{N}.xml.rels`, `notesSlide{N}.xml.rels`, and
|
|
185
|
+
* `[Content_Types].xml` — as a parsed document, then re-serializes the cache over
|
|
186
|
+
* the zip at write() time. A plain `write`/`remove` to those parts is therefore
|
|
187
|
+
* silently reverted at the final flush. This adapter reads the buffered
|
|
188
|
+
* (automizer-current) content and steers writes/removes back through the same
|
|
189
|
+
* buffer, so `applyNotesToSlide` stays pure over string+xmldom while its edits
|
|
190
|
+
* survive. The notes PART (`notesSlide{N}.xml`) is copied raw and never buffered,
|
|
191
|
+
* so it round-trips as a plain zip entry. The buffer holds docs parsed by the one
|
|
192
|
+
* installed `@xmldom/xmldom`, so our parser/serializer operate on them safely.
|
|
193
|
+
*/
|
|
194
|
+
function toNotesArchive(target) {
|
|
195
|
+
// Fail loud if automizer's internals no longer expose the buffer array this
|
|
196
|
+
// adapter steers writes through: without it, every buffered write would be
|
|
197
|
+
// silently reverted at the final flush, corrupting the deck with no error.
|
|
198
|
+
if (!Array.isArray(target.buffer)) {
|
|
199
|
+
throw new Error("Speaker notes: expected the pptx-automizer archive to expose a `buffer` array " +
|
|
200
|
+
`(pinned to pptx-automizer ${PPTX_AUTOMIZER_VERSION}); its internals may have changed. ` +
|
|
201
|
+
"Re-verify the notes buffer adapter before relying on it.");
|
|
202
|
+
}
|
|
203
|
+
const buffer = target.buffer;
|
|
204
|
+
const buffered = (file) => buffer.find((entry) => entry.relativePath === file);
|
|
205
|
+
return {
|
|
206
|
+
async read(file, type) {
|
|
207
|
+
const entry = buffered(file);
|
|
208
|
+
return entry ? new XMLSerializer().serializeToString(entry.content) : target.read(file, type);
|
|
209
|
+
},
|
|
210
|
+
async write(file, data) {
|
|
211
|
+
const entry = buffered(file);
|
|
212
|
+
if (entry)
|
|
213
|
+
entry.content = new DOMParser().parseFromString(data, "application/xml");
|
|
214
|
+
return target.write(file, data);
|
|
215
|
+
},
|
|
216
|
+
async remove(file) {
|
|
217
|
+
const index = buffer.findIndex((entry) => entry.relativePath === file);
|
|
218
|
+
if (index !== -1)
|
|
219
|
+
buffer.splice(index, 1);
|
|
220
|
+
await target.remove(file);
|
|
221
|
+
},
|
|
222
|
+
fileExists(file) {
|
|
223
|
+
return buffered(file) !== undefined || target.fileExists(file);
|
|
224
|
+
},
|
|
225
|
+
async folder(dir) {
|
|
226
|
+
return target.folder(dir);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
148
230
|
// ── Content-slot validation (test-visible helper) ────────────────────────────
|
|
149
231
|
/**
|
|
150
232
|
* Validate that every required slot on a layout is supplied. Type/shape
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { fillImage } from "./fillers/image.js";
|
|
|
3
3
|
export { fillTable, isTableFill } from "./fillers/table.js";
|
|
4
4
|
export { fillTemplate } from "./fillers/template.js";
|
|
5
5
|
export { fillText, isTextFill } from "./fillers/text.js";
|
|
6
|
+
export type { GenerateOptions } from "./generate.js";
|
|
6
7
|
export { generate } from "./generate.js";
|
|
7
8
|
export type { Config, Deck, DeckStep, ImageFill, Layout, Slot, StyledParagraph, TableFill, TemplateFill, TemplateSegment, TextFill, TextRun, ThemeConfig, } from "./types.js";
|
|
8
|
-
export {
|
|
9
|
+
export { ImageFit, SlotType } from "./types.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -4,4 +4,4 @@ export { fillTable, isTableFill } from "./fillers/table.js";
|
|
|
4
4
|
export { fillTemplate } from "./fillers/template.js";
|
|
5
5
|
export { fillText, isTextFill } from "./fillers/text.js";
|
|
6
6
|
export { generate } from "./generate.js";
|
|
7
|
-
export {
|
|
7
|
+
export { ImageFit, SlotType } from "./types.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker-notes injection — an in-band pass run per slide during `write()`.
|
|
3
|
+
*
|
|
4
|
+
* The render library (pptx-automizer) has no public notes writer, but it does
|
|
5
|
+
* expose a general seam: `slide.modify((document, parent) => …)` runs while the
|
|
6
|
+
* deck is being written and hands us `parent.targetArchive` (the OUTPUT archive)
|
|
7
|
+
* and `parent.targetNumber` (the real output slide number). automizer's clone
|
|
8
|
+
* auto-copies the source slide's notes as `notesSlide${targetNumber}.xml` and
|
|
9
|
+
* wires its rels + content-type BEFORE our callback runs, so notes map 1:1 to the
|
|
10
|
+
* output slide number — no `sldIdLst`→rels→slideK mapping is needed.
|
|
11
|
+
*
|
|
12
|
+
* For each slide we either overwrite/synthesize its `notesSlide${N}.xml` (when the
|
|
13
|
+
* step authored notes) or strip any auto-copied part (when it did not, or notes
|
|
14
|
+
* are excluded) — so the designer's template notes never leak into the output.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The minimal archive surface `applyNotesToSlide` needs. pptx-automizer's
|
|
18
|
+
* `IArchive` satisfies it structurally (so we never import its internal type), and
|
|
19
|
+
* the test fake implements it over a `Map`. String-based `read`/`write` only —
|
|
20
|
+
* XML is parsed/serialized with the engine's own `@xmldom/xmldom` instance, never
|
|
21
|
+
* automizer's `readXml`/`writeXml` (mixing xmldom instances risks subtle bugs).
|
|
22
|
+
*/
|
|
23
|
+
export type NotesArchive = {
|
|
24
|
+
read(file: string, type: "string"): Promise<string | Buffer>;
|
|
25
|
+
write(file: string, data: string): Promise<unknown>;
|
|
26
|
+
remove(file: string): Promise<void>;
|
|
27
|
+
fileExists(file: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Top-level entries of `dir` (nested `_rels` excluded). `name` is the FULL
|
|
30
|
+
* archive path (e.g. `ppt/notesSlides/notesSlide1.xml`). pptx-automizer's
|
|
31
|
+
* `IArchive.folder` satisfies this structurally.
|
|
32
|
+
*/
|
|
33
|
+
folder(dir: string): Promise<{
|
|
34
|
+
name: string;
|
|
35
|
+
relativePath: string;
|
|
36
|
+
}[]>;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Build a minimal `<p:notes>` part whose body placeholder carries one `<a:p>`
|
|
40
|
+
* per line of `notes`. Text goes through DOM text nodes, so `& < > "` escaping
|
|
41
|
+
* is handled by the serializer.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildNotesSlideXml(notes: string): string;
|
|
44
|
+
/** Next unused `rIdN` in a `.rels` XML string (max existing id + 1). */
|
|
45
|
+
export declare function nextFreeRId(relsXml: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Remove every notesSlide part that no live slide references. automizer drops the
|
|
48
|
+
* template's orphaned *slides* from the deck when building it but leaves all their
|
|
49
|
+
* `notesSlides` behind as unreferenced parts (plus their `[Content_Types].xml`
|
|
50
|
+
* Overrides), so the designer's private notes would otherwise leak into every
|
|
51
|
+
* output. Run once as a presentation-level pass, after the deck is assembled.
|
|
52
|
+
*
|
|
53
|
+
* A notesSlide is "referenced" iff a LIVE slide (reachable from `presentation.xml`,
|
|
54
|
+
* see {@link liveSlideParts}) carries a `/notesSlide` relationship pointing at it.
|
|
55
|
+
* Everything else is swept: the part, its rels, and its content-type Override (all
|
|
56
|
+
* Override removals batched into a single `[Content_Types].xml` read+write).
|
|
57
|
+
*/
|
|
58
|
+
export declare function sweepOrphanNotes(archive: NotesArchive): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Set (or strip) the speaker notes on one output slide, keyed on its real output
|
|
61
|
+
* `slideNumber` (`parent.targetNumber`). Runs inside `slide.modify` during
|
|
62
|
+
* `write()`, operating on the OUTPUT archive.
|
|
63
|
+
*
|
|
64
|
+
* - Authored notes (non-empty and `!excludeNotes`): if automizer auto-copied a
|
|
65
|
+
* notes part (`notesSlide${N}.xml` already exists), overwrite its text only —
|
|
66
|
+
* its rels and content-type are already correct. Otherwise synthesize the part,
|
|
67
|
+
* its rels (→slide + →master), a `notesSlide` rel on the slide (next free rId),
|
|
68
|
+
* and the `[Content_Types].xml` override.
|
|
69
|
+
* - No notes (or excluded): if a part was auto-copied, remove it, its rels, the
|
|
70
|
+
* slide's `notesSlide` rel, and the content-type override — so the designer's
|
|
71
|
+
* template notes never leak.
|
|
72
|
+
*
|
|
73
|
+
* Throws (no silent default) if a slide needs a NEW notes part but the template
|
|
74
|
+
* ships no notes master — synthesizing one is out of scope.
|
|
75
|
+
*/
|
|
76
|
+
export declare function applyNotesToSlide(archive: NotesArchive, slideNumber: number, notes: string | undefined, excludeNotes: boolean): Promise<void>;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker-notes injection — an in-band pass run per slide during `write()`.
|
|
3
|
+
*
|
|
4
|
+
* The render library (pptx-automizer) has no public notes writer, but it does
|
|
5
|
+
* expose a general seam: `slide.modify((document, parent) => …)` runs while the
|
|
6
|
+
* deck is being written and hands us `parent.targetArchive` (the OUTPUT archive)
|
|
7
|
+
* and `parent.targetNumber` (the real output slide number). automizer's clone
|
|
8
|
+
* auto-copies the source slide's notes as `notesSlide${targetNumber}.xml` and
|
|
9
|
+
* wires its rels + content-type BEFORE our callback runs, so notes map 1:1 to the
|
|
10
|
+
* output slide number — no `sldIdLst`→rels→slideK mapping is needed.
|
|
11
|
+
*
|
|
12
|
+
* For each slide we either overwrite/synthesize its `notesSlide${N}.xml` (when the
|
|
13
|
+
* step authored notes) or strip any auto-copied part (when it did not, or notes
|
|
14
|
+
* are excluded) — so the designer's template notes never leak into the output.
|
|
15
|
+
*/
|
|
16
|
+
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
|
|
17
|
+
import { Attr, buildParagraph, buildRun, nextFreeRId as nextFreeRIdForDoc, Tag } from "./dom.js";
|
|
18
|
+
// Package-level OOXML names not covered by dom.ts's `Tag`/`Attr` (which name
|
|
19
|
+
// slide-XML tokens): `[Content_Types].xml` Override entries and the tail
|
|
20
|
+
// segments of relationship-type URLs.
|
|
21
|
+
const CT_OVERRIDE = "Override";
|
|
22
|
+
const CT_PART_NAME = "PartName";
|
|
23
|
+
const CT_CONTENT_TYPE = "ContentType";
|
|
24
|
+
const REL_TYPE_SUFFIX = {
|
|
25
|
+
NotesSlide: "/notesSlide",
|
|
26
|
+
NotesMaster: "/notesMaster",
|
|
27
|
+
};
|
|
28
|
+
const NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main";
|
|
29
|
+
const NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main";
|
|
30
|
+
const NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
31
|
+
const NS_REL = "http://schemas.openxmlformats.org/package/2006/relationships";
|
|
32
|
+
const NS_CT = "http://schemas.openxmlformats.org/package/2006/content-types";
|
|
33
|
+
const REL_TYPE = {
|
|
34
|
+
NotesSlide: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
|
|
35
|
+
Slide: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
|
|
36
|
+
NotesMaster: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster",
|
|
37
|
+
};
|
|
38
|
+
const NOTES_SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml";
|
|
39
|
+
const XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n';
|
|
40
|
+
const CONTENT_TYPES_PART = "[Content_Types].xml";
|
|
41
|
+
const PRESENTATION_RELS_PART = "ppt/_rels/presentation.xml.rels";
|
|
42
|
+
const NOTES_SKELETON = `<p:notes xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:cSld><p:spTree>` +
|
|
43
|
+
`<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>` +
|
|
44
|
+
`<p:sp><p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder 1"/>` +
|
|
45
|
+
`<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>` +
|
|
46
|
+
`<p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/></p:txBody></p:sp>` +
|
|
47
|
+
`</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>`;
|
|
48
|
+
/**
|
|
49
|
+
* Build a minimal `<p:notes>` part whose body placeholder carries one `<a:p>`
|
|
50
|
+
* per line of `notes`. Text goes through DOM text nodes, so `& < > "` escaping
|
|
51
|
+
* is handled by the serializer.
|
|
52
|
+
*/
|
|
53
|
+
export function buildNotesSlideXml(notes) {
|
|
54
|
+
const doc = new DOMParser().parseFromString(NOTES_SKELETON, "text/xml");
|
|
55
|
+
const txBody = doc.getElementsByTagName("p:txBody")[0];
|
|
56
|
+
for (const line of notes.replace(/\r?\n+$/, "").split(/\r?\n/)) {
|
|
57
|
+
// Reuse the engine's run/paragraph builders — they set xml:space="preserve"
|
|
58
|
+
// for leading/trailing whitespace. The skeleton declares xmlns:a, so the
|
|
59
|
+
// builders' "a:"-prefixed element creation serializes correctly on this doc.
|
|
60
|
+
txBody.appendChild(buildParagraph(doc, null, buildRun(doc, null, line)));
|
|
61
|
+
}
|
|
62
|
+
return serialize(doc);
|
|
63
|
+
}
|
|
64
|
+
/** Next unused `rIdN` in a `.rels` XML string (max existing id + 1). */
|
|
65
|
+
export function nextFreeRId(relsXml) {
|
|
66
|
+
return nextFreeRIdForDoc(new DOMParser().parseFromString(relsXml, "text/xml"));
|
|
67
|
+
}
|
|
68
|
+
function serialize(doc) {
|
|
69
|
+
// xmldom re-emits any `<?xml?>` declaration present in the parsed source, so a
|
|
70
|
+
// doc parsed from an already-declared part would serialize with two of them.
|
|
71
|
+
// Strip any leading declaration, then prepend exactly one canonical XML_DECL.
|
|
72
|
+
const body = new XMLSerializer().serializeToString(doc).replace(/^<\?xml[^>]*\?>\s*/, "");
|
|
73
|
+
return XML_DECL + body;
|
|
74
|
+
}
|
|
75
|
+
function parse(xml) {
|
|
76
|
+
return new DOMParser().parseFromString(xml, "text/xml");
|
|
77
|
+
}
|
|
78
|
+
async function readString(archive, file) {
|
|
79
|
+
const raw = await archive.read(file, "string");
|
|
80
|
+
return typeof raw === "string" ? raw : raw.toString("utf-8");
|
|
81
|
+
}
|
|
82
|
+
function appendRel(relsDoc, id, type, target) {
|
|
83
|
+
const rel = relsDoc.createElementNS(NS_REL, Tag.RELATIONSHIP);
|
|
84
|
+
rel.setAttribute(Attr.ID, id);
|
|
85
|
+
rel.setAttribute(Attr.TYPE, type);
|
|
86
|
+
rel.setAttribute(Attr.TARGET, target);
|
|
87
|
+
relsDoc.documentElement.appendChild(rel);
|
|
88
|
+
}
|
|
89
|
+
/** Remove the (single) notesSlide relationship from a slide's rels doc, if present. */
|
|
90
|
+
function removeNotesRel(relsDoc) {
|
|
91
|
+
const rels = relsDoc.getElementsByTagName(Tag.RELATIONSHIP);
|
|
92
|
+
for (let i = 0; i < rels.length; i++) {
|
|
93
|
+
if (rels[i].getAttribute(Attr.TYPE) === REL_TYPE.NotesSlide) {
|
|
94
|
+
rels[i].parentNode?.removeChild(rels[i]);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
function notesRelsXml(slideNumber, masterTarget) {
|
|
101
|
+
return (XML_DECL +
|
|
102
|
+
`<Relationships xmlns="${NS_REL}">` +
|
|
103
|
+
`<Relationship Id="rId1" Type="${REL_TYPE.Slide}" Target="../slides/slide${slideNumber}.xml"/>` +
|
|
104
|
+
`<Relationship Id="rId2" Type="${REL_TYPE.NotesMaster}" Target="${masterTarget}"/>` +
|
|
105
|
+
`</Relationships>`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The notes master's target relative to `ppt/notesSlides/` (what a notesSlide's
|
|
109
|
+
* rels needs), read from the package's `presentation.xml.rels`. That file's
|
|
110
|
+
* targets are relative to `ppt/` (e.g. `notesMasters/notesMaster1.xml`), so a
|
|
111
|
+
* notesSlide part one directory deeper prefixes `../`. Returns undefined when
|
|
112
|
+
* the presentation declares no notes master relationship.
|
|
113
|
+
*/
|
|
114
|
+
async function findNotesMaster(archive) {
|
|
115
|
+
if (!archive.fileExists(PRESENTATION_RELS_PART))
|
|
116
|
+
return undefined;
|
|
117
|
+
const relsDoc = parse(await readString(archive, PRESENTATION_RELS_PART));
|
|
118
|
+
const rels = relsDoc.getElementsByTagName(Tag.RELATIONSHIP);
|
|
119
|
+
for (let i = 0; i < rels.length; i++) {
|
|
120
|
+
if (rels[i].getAttribute(Attr.TYPE)?.endsWith(REL_TYPE_SUFFIX.NotesMaster)) {
|
|
121
|
+
return `../${rels[i].getAttribute(Attr.TARGET)}`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
function findOverride(ctDoc, partName) {
|
|
127
|
+
const overrides = ctDoc.getElementsByTagName(CT_OVERRIDE);
|
|
128
|
+
for (let i = 0; i < overrides.length; i++) {
|
|
129
|
+
if (overrides[i].getAttribute(CT_PART_NAME) === partName)
|
|
130
|
+
return overrides[i];
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
async function addContentTypeOverride(archive, partName) {
|
|
135
|
+
const ctDoc = parse(await readString(archive, CONTENT_TYPES_PART));
|
|
136
|
+
if (findOverride(ctDoc, partName))
|
|
137
|
+
return;
|
|
138
|
+
const override = ctDoc.createElementNS(NS_CT, CT_OVERRIDE);
|
|
139
|
+
override.setAttribute(CT_PART_NAME, partName);
|
|
140
|
+
override.setAttribute(CT_CONTENT_TYPE, NOTES_SLIDE_CONTENT_TYPE);
|
|
141
|
+
ctDoc.documentElement.appendChild(override);
|
|
142
|
+
await archive.write(CONTENT_TYPES_PART, serialize(ctDoc));
|
|
143
|
+
}
|
|
144
|
+
/** Remove a part's `<Override>` from an already-parsed `[Content_Types].xml` doc. */
|
|
145
|
+
function removeOverrideFromDoc(ctDoc, partName) {
|
|
146
|
+
const override = findOverride(ctDoc, partName);
|
|
147
|
+
if (!override)
|
|
148
|
+
return false;
|
|
149
|
+
override.parentNode?.removeChild(override);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
async function removeContentTypeOverride(archive, partName) {
|
|
153
|
+
const ctDoc = parse(await readString(archive, CONTENT_TYPES_PART));
|
|
154
|
+
if (!removeOverrideFromDoc(ctDoc, partName))
|
|
155
|
+
return;
|
|
156
|
+
await archive.write(CONTENT_TYPES_PART, serialize(ctDoc));
|
|
157
|
+
}
|
|
158
|
+
/** Resolve `target` (from a rels file) against `baseDir`, collapsing `.`/`..`. */
|
|
159
|
+
function resolveRelative(baseDir, target) {
|
|
160
|
+
const parts = baseDir.split("/").filter(Boolean);
|
|
161
|
+
for (const seg of target.split("/")) {
|
|
162
|
+
if (seg === "" || seg === ".")
|
|
163
|
+
continue;
|
|
164
|
+
if (seg === "..")
|
|
165
|
+
parts.pop();
|
|
166
|
+
else
|
|
167
|
+
parts.push(seg);
|
|
168
|
+
}
|
|
169
|
+
return parts.join("/");
|
|
170
|
+
}
|
|
171
|
+
const NOTES_SLIDE_PART = /^ppt\/notesSlides\/notesSlide\d+\.xml$/;
|
|
172
|
+
const PRESENTATION_PART = "ppt/presentation.xml";
|
|
173
|
+
/**
|
|
174
|
+
* The slide parts a deck actually presents, in `<p:sldIdLst>` order, resolved via
|
|
175
|
+
* `presentation.xml.rels`. Returns full archive paths (e.g. `ppt/slides/slide1.xml`).
|
|
176
|
+
*
|
|
177
|
+
* This deliberately does NOT enumerate `ppt/slides/_rels`: when automizer builds a
|
|
178
|
+
* deck it removes the template's original slides from `<p:sldIdLst>` first, but
|
|
179
|
+
* leaves their physical `slideN.xml`/`slideN.xml.rels` parts in the archive until
|
|
180
|
+
* a later cleanup pass. Those orphaned slide rels still carry `/notesSlide`
|
|
181
|
+
* relationships, so folder-enumeration would wrongly count template notes as
|
|
182
|
+
* referenced. Only slides reachable from `presentation.xml` are live.
|
|
183
|
+
*/
|
|
184
|
+
async function liveSlideParts(archive) {
|
|
185
|
+
const pres = parse(await readString(archive, PRESENTATION_PART));
|
|
186
|
+
const sldIds = pres.getElementsByTagName("p:sldId");
|
|
187
|
+
const rIds = [];
|
|
188
|
+
for (let i = 0; i < sldIds.length; i++) {
|
|
189
|
+
const rid = sldIds[i].getAttributeNS(NS_R, "id") || sldIds[i].getAttribute(Attr.HLINK_ID);
|
|
190
|
+
if (rid)
|
|
191
|
+
rIds.push(rid);
|
|
192
|
+
}
|
|
193
|
+
const relsDoc = parse(await readString(archive, PRESENTATION_RELS_PART));
|
|
194
|
+
const rels = relsDoc.getElementsByTagName(Tag.RELATIONSHIP);
|
|
195
|
+
const targetById = new Map();
|
|
196
|
+
for (let i = 0; i < rels.length; i++) {
|
|
197
|
+
const id = rels[i].getAttribute(Attr.ID);
|
|
198
|
+
const target = rels[i].getAttribute(Attr.TARGET);
|
|
199
|
+
if (id && target)
|
|
200
|
+
targetById.set(id, target);
|
|
201
|
+
}
|
|
202
|
+
const parts = [];
|
|
203
|
+
for (const rid of rIds) {
|
|
204
|
+
const target = targetById.get(rid);
|
|
205
|
+
if (target)
|
|
206
|
+
parts.push(resolveRelative("ppt", target));
|
|
207
|
+
}
|
|
208
|
+
return parts;
|
|
209
|
+
}
|
|
210
|
+
/** The `_rels` path for an archive part (`ppt/slides/slide1.xml` → `ppt/slides/_rels/slide1.xml.rels`). */
|
|
211
|
+
function relsPathFor(part) {
|
|
212
|
+
return part.replace(/\/([^/]+)$/, "/_rels/$1.rels");
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Remove every notesSlide part that no live slide references. automizer drops the
|
|
216
|
+
* template's orphaned *slides* from the deck when building it but leaves all their
|
|
217
|
+
* `notesSlides` behind as unreferenced parts (plus their `[Content_Types].xml`
|
|
218
|
+
* Overrides), so the designer's private notes would otherwise leak into every
|
|
219
|
+
* output. Run once as a presentation-level pass, after the deck is assembled.
|
|
220
|
+
*
|
|
221
|
+
* A notesSlide is "referenced" iff a LIVE slide (reachable from `presentation.xml`,
|
|
222
|
+
* see {@link liveSlideParts}) carries a `/notesSlide` relationship pointing at it.
|
|
223
|
+
* Everything else is swept: the part, its rels, and its content-type Override (all
|
|
224
|
+
* Override removals batched into a single `[Content_Types].xml` read+write).
|
|
225
|
+
*/
|
|
226
|
+
export async function sweepOrphanNotes(archive) {
|
|
227
|
+
const notesParts = (await archive.folder("ppt/notesSlides"))
|
|
228
|
+
.map((entry) => entry.name)
|
|
229
|
+
.filter((name) => NOTES_SLIDE_PART.test(name));
|
|
230
|
+
if (notesParts.length === 0)
|
|
231
|
+
return;
|
|
232
|
+
const referenced = new Set();
|
|
233
|
+
for (const slidePart of await liveSlideParts(archive)) {
|
|
234
|
+
const relsPath = relsPathFor(slidePart);
|
|
235
|
+
if (!archive.fileExists(relsPath))
|
|
236
|
+
continue;
|
|
237
|
+
const rels = parse(await readString(archive, relsPath)).getElementsByTagName(Tag.RELATIONSHIP);
|
|
238
|
+
for (let i = 0; i < rels.length; i++) {
|
|
239
|
+
const target = rels[i].getAttribute(Attr.TARGET);
|
|
240
|
+
if (target && rels[i].getAttribute(Attr.TYPE)?.endsWith(REL_TYPE_SUFFIX.NotesSlide)) {
|
|
241
|
+
referenced.add(resolveRelative("ppt/slides", target));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const orphans = notesParts.filter((part) => !referenced.has(part));
|
|
246
|
+
if (orphans.length === 0)
|
|
247
|
+
return;
|
|
248
|
+
const ctDoc = parse(await readString(archive, CONTENT_TYPES_PART));
|
|
249
|
+
let ctChanged = false;
|
|
250
|
+
for (const part of orphans) {
|
|
251
|
+
await archive.remove(part);
|
|
252
|
+
const relsPart = `ppt/notesSlides/_rels/${part.slice(part.lastIndexOf("/") + 1)}.rels`;
|
|
253
|
+
if (archive.fileExists(relsPart))
|
|
254
|
+
await archive.remove(relsPart);
|
|
255
|
+
if (removeOverrideFromDoc(ctDoc, `/${part}`))
|
|
256
|
+
ctChanged = true;
|
|
257
|
+
}
|
|
258
|
+
if (ctChanged)
|
|
259
|
+
await archive.write(CONTENT_TYPES_PART, serialize(ctDoc));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Set (or strip) the speaker notes on one output slide, keyed on its real output
|
|
263
|
+
* `slideNumber` (`parent.targetNumber`). Runs inside `slide.modify` during
|
|
264
|
+
* `write()`, operating on the OUTPUT archive.
|
|
265
|
+
*
|
|
266
|
+
* - Authored notes (non-empty and `!excludeNotes`): if automizer auto-copied a
|
|
267
|
+
* notes part (`notesSlide${N}.xml` already exists), overwrite its text only —
|
|
268
|
+
* its rels and content-type are already correct. Otherwise synthesize the part,
|
|
269
|
+
* its rels (→slide + →master), a `notesSlide` rel on the slide (next free rId),
|
|
270
|
+
* and the `[Content_Types].xml` override.
|
|
271
|
+
* - No notes (or excluded): if a part was auto-copied, remove it, its rels, the
|
|
272
|
+
* slide's `notesSlide` rel, and the content-type override — so the designer's
|
|
273
|
+
* template notes never leak.
|
|
274
|
+
*
|
|
275
|
+
* Throws (no silent default) if a slide needs a NEW notes part but the template
|
|
276
|
+
* ships no notes master — synthesizing one is out of scope.
|
|
277
|
+
*/
|
|
278
|
+
export async function applyNotesToSlide(archive, slideNumber, notes, excludeNotes) {
|
|
279
|
+
const notesPath = `ppt/notesSlides/notesSlide${slideNumber}.xml`;
|
|
280
|
+
const notesRelsPath = `ppt/notesSlides/_rels/notesSlide${slideNumber}.xml.rels`;
|
|
281
|
+
const slideRelsPath = `ppt/slides/_rels/slide${slideNumber}.xml.rels`;
|
|
282
|
+
const notesExists = archive.fileExists(notesPath);
|
|
283
|
+
const shouldWrite = notes !== undefined && notes !== "" && !excludeNotes;
|
|
284
|
+
if (shouldWrite) {
|
|
285
|
+
if (notesExists) {
|
|
286
|
+
// Auto-copied part: overwrite its body; leave rels + content-type as wired.
|
|
287
|
+
await archive.write(notesPath, buildNotesSlideXml(notes));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const masterTarget = await findNotesMaster(archive);
|
|
291
|
+
if (masterTarget === undefined) {
|
|
292
|
+
throw new Error(`Speaker notes: slide ${slideNumber} has notes, but the template ships no notes master ` +
|
|
293
|
+
"(ppt/notesMasters/). Add a notes master to the template (PowerPoint: View → Notes Master), " +
|
|
294
|
+
"or build with notes disabled. Synthesizing a notes master is not supported.");
|
|
295
|
+
}
|
|
296
|
+
await archive.write(notesPath, buildNotesSlideXml(notes));
|
|
297
|
+
await archive.write(notesRelsPath, notesRelsXml(slideNumber, masterTarget));
|
|
298
|
+
const relsDoc = parse(await readString(archive, slideRelsPath));
|
|
299
|
+
appendRel(relsDoc, nextFreeRIdForDoc(relsDoc), REL_TYPE.NotesSlide, `../notesSlides/notesSlide${slideNumber}.xml`);
|
|
300
|
+
await archive.write(slideRelsPath, serialize(relsDoc));
|
|
301
|
+
await addContentTypeOverride(archive, `/${notesPath}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!notesExists)
|
|
305
|
+
return;
|
|
306
|
+
await archive.remove(notesPath);
|
|
307
|
+
if (archive.fileExists(notesRelsPath))
|
|
308
|
+
await archive.remove(notesRelsPath);
|
|
309
|
+
const relsDoc = parse(await readString(archive, slideRelsPath));
|
|
310
|
+
if (removeNotesRel(relsDoc))
|
|
311
|
+
await archive.write(slideRelsPath, serialize(relsDoc));
|
|
312
|
+
await removeContentTypeOverride(archive, `/${notesPath}`);
|
|
313
|
+
}
|
package/dist/engine/types.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/** How the engine should scale an image inside its picture frame. */
|
|
2
|
-
export declare const FitMode: {
|
|
3
|
-
/** Shrink the picture frame to the image's aspect ratio, centered. */
|
|
4
|
-
readonly Contain: "contain";
|
|
5
|
-
/** Fill the frame; center-crop overflow via srcRect. */
|
|
6
|
-
readonly Cover: "cover";
|
|
7
|
-
};
|
|
8
|
-
export type FitMode = (typeof FitMode)[keyof typeof FitMode];
|
|
9
1
|
/**
|
|
10
2
|
* Fill-strategy discriminator carried on every Slot. Required — there is no
|
|
11
3
|
* silent default. The engine dispatches to fillTemplate / fillText / fillTable /
|
|
@@ -80,15 +72,26 @@ export type TableFill = {
|
|
|
80
72
|
rows: StyledParagraph[][];
|
|
81
73
|
};
|
|
82
74
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
75
|
+
* How a picture is scaled into its frame — the engine's image-sizing directive,
|
|
76
|
+
* mirroring CSS `object-fit`. `contain`: fit the whole image, scale both ways,
|
|
77
|
+
* letterbox. `cover`: fill the frame, centre-crop the overflow. `scale-down`:
|
|
78
|
+
* like contain but never enlarge past native (a small image sits at native size).
|
|
79
|
+
*/
|
|
80
|
+
export declare const ImageFit: {
|
|
81
|
+
readonly Contain: "contain";
|
|
82
|
+
readonly Cover: "cover";
|
|
83
|
+
readonly ScaleDown: "scale-down";
|
|
84
|
+
};
|
|
85
|
+
export type ImageFit = (typeof ImageFit)[keyof typeof ImageFit];
|
|
86
|
+
/**
|
|
87
|
+
* Input to fillImage — a resolved image path plus its object-fit directive.
|
|
88
|
+
* `path` must be absolute; the compiler resolves it (and maps the asset's
|
|
89
|
+
* semantic type to a `fit`) before the ImageFill reaches the engine.
|
|
87
90
|
*/
|
|
88
91
|
export type ImageFill = {
|
|
89
92
|
type: typeof SlotType.Image;
|
|
90
93
|
path: string;
|
|
91
|
-
fit:
|
|
94
|
+
fit: ImageFit;
|
|
92
95
|
};
|
|
93
96
|
export type Slot = {
|
|
94
97
|
key: string;
|
|
@@ -97,8 +100,6 @@ export type Slot = {
|
|
|
97
100
|
type: SlotType;
|
|
98
101
|
/** Leave the first N specimen paragraphs untouched (fillText only). */
|
|
99
102
|
startAt?: number;
|
|
100
|
-
/** Enforced column count for table slots. */
|
|
101
|
-
columns?: number;
|
|
102
103
|
};
|
|
103
104
|
export type Layout = {
|
|
104
105
|
name: string;
|
|
@@ -111,6 +112,8 @@ export type Layout = {
|
|
|
111
112
|
export type DeckStep = {
|
|
112
113
|
layout: string;
|
|
113
114
|
content?: Record<string, TextFill | TableFill | ImageFill | TemplateFill>;
|
|
115
|
+
/** Slide-level speaker notes — plain text, one paragraph per line. Not a slot. */
|
|
116
|
+
notes?: string;
|
|
114
117
|
};
|
|
115
118
|
export type Deck = {
|
|
116
119
|
theme: string;
|
package/dist/engine/types.js
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/** How the engine should scale an image inside its picture frame. */
|
|
2
|
-
export const FitMode = {
|
|
3
|
-
/** Shrink the picture frame to the image's aspect ratio, centered. */
|
|
4
|
-
Contain: "contain",
|
|
5
|
-
/** Fill the frame; center-crop overflow via srcRect. */
|
|
6
|
-
Cover: "cover",
|
|
7
|
-
};
|
|
8
1
|
/**
|
|
9
2
|
* Fill-strategy discriminator carried on every Slot. Required — there is no
|
|
10
3
|
* silent default. The engine dispatches to fillTemplate / fillText / fillTable /
|
|
@@ -20,3 +13,14 @@ export const SlotType = {
|
|
|
20
13
|
/** fillImage: swap the picture's blip and adjust frame geometry. */
|
|
21
14
|
Image: "image",
|
|
22
15
|
};
|
|
16
|
+
/**
|
|
17
|
+
* How a picture is scaled into its frame — the engine's image-sizing directive,
|
|
18
|
+
* mirroring CSS `object-fit`. `contain`: fit the whole image, scale both ways,
|
|
19
|
+
* letterbox. `cover`: fill the frame, centre-crop the overflow. `scale-down`:
|
|
20
|
+
* like contain but never enlarge past native (a small image sits at native size).
|
|
21
|
+
*/
|
|
22
|
+
export const ImageFit = {
|
|
23
|
+
Contain: "contain",
|
|
24
|
+
Cover: "cover",
|
|
25
|
+
ScaleDown: "scale-down",
|
|
26
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Config, type ThemeConfig } from "./engine/index.js";
|
|
1
|
+
import { type Config, type GenerateOptions, type ThemeConfig } from "./engine/index.js";
|
|
2
2
|
import { type CompilerConfig, type CompilerDeck, type CompilerThemeConfig, type ResolvedCompilerDeck } from "./markdown/types.js";
|
|
3
3
|
/**
|
|
4
4
|
* Run every compiler-owned resolver over `deck` (highlight code fences,
|
|
@@ -37,9 +37,9 @@ export declare function toEngineConfig(config: CompilerConfig): Config;
|
|
|
37
37
|
* Mermaid PNGs are cached under `<outputDir>/.tycoslide-cache/mermaid/` so no
|
|
38
38
|
* post-write cleanup is needed.
|
|
39
39
|
*/
|
|
40
|
-
export declare function buildDeck(deck: CompilerDeck, config: CompilerConfig): Promise<void>;
|
|
41
|
-
export type { Config, Deck, DeckStep, ImageFill, Layout, Slot, StyledParagraph, TableFill, TextFill, TextRun, ThemeConfig, } from "./engine/index.js";
|
|
42
|
-
export {
|
|
40
|
+
export declare function buildDeck(deck: CompilerDeck, config: CompilerConfig, options?: GenerateOptions): Promise<void>;
|
|
41
|
+
export type { Config, Deck, DeckStep, GenerateOptions, ImageFill, Layout, Slot, StyledParagraph, TableFill, TextFill, TextRun, ThemeConfig, } from "./engine/index.js";
|
|
42
|
+
export { fillImage, fillTable, fillTemplate, fillText, generate, SlotType } from "./engine/index.js";
|
|
43
43
|
export type { ManifestOptions } from "./manifest.js";
|
|
44
44
|
export { generateManifest } from "./manifest.js";
|
|
45
45
|
export type { AssetCatalog, AssetEntry, CodeFence, CompilerConfig, CompilerDeck, CompilerDeckStep, CompilerLayout, CompilerParameter, CompilerSlot, CompilerThemeConfig, MarkdownBlock, MermaidConfig, MermaidFence, MermaidVariant, ParsedDocument, RawSlide, } from "./markdown/index.js";
|