@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.
@@ -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) => fillSlide(slide, layout, step));
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
@@ -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 { FitMode, SlotType } from "./types.js";
9
+ export { ImageFit, SlotType } from "./types.js";
@@ -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 { FitMode, SlotType } from "./types.js";
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
+ }
@@ -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
- * Input to fillImage a resolved image path plus its fit mode. `path` must
84
- * be an absolute filesystem path; the compiler is responsible for resolution
85
- * before an ImageFill reaches the engine. Relative paths fail loudly at
86
- * `readFileSync` / `imageSize` in the engine.
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: FitMode;
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;
@@ -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 { FitMode, fillImage, fillTable, fillTemplate, fillText, generate, SlotType } from "./engine/index.js";
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";