@tycoworks/tycoslide 0.7.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/LICENSE +21 -0
- package/README.md +73 -0
- package/SKILL.md +249 -0
- package/bin/tycoslide.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +197 -0
- package/dist/engine/dom.d.ts +92 -0
- package/dist/engine/dom.js +354 -0
- package/dist/engine/fillers/filler.d.ts +22 -0
- package/dist/engine/fillers/filler.js +53 -0
- package/dist/engine/fillers/image.d.ts +19 -0
- package/dist/engine/fillers/image.js +105 -0
- package/dist/engine/fillers/table.d.ts +21 -0
- package/dist/engine/fillers/table.js +62 -0
- package/dist/engine/fillers/template.d.ts +27 -0
- package/dist/engine/fillers/template.js +221 -0
- package/dist/engine/fillers/text.d.ts +28 -0
- package/dist/engine/fillers/text.js +29 -0
- package/dist/engine/generate.d.ts +51 -0
- package/dist/engine/generate.js +161 -0
- package/dist/engine/index.d.ts +8 -0
- package/dist/engine/index.js +7 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +22 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +146 -0
- package/dist/manifest.d.ts +7 -0
- package/dist/manifest.js +88 -0
- package/dist/markdown/deckCompiler.d.ts +36 -0
- package/dist/markdown/deckCompiler.js +289 -0
- package/dist/markdown/index.d.ts +13 -0
- package/dist/markdown/index.js +13 -0
- package/dist/markdown/parsers.d.ts +32 -0
- package/dist/markdown/parsers.js +233 -0
- package/dist/markdown/resolvers/code.d.ts +17 -0
- package/dist/markdown/resolvers/code.js +44 -0
- package/dist/markdown/resolvers/mermaid.d.ts +14 -0
- package/dist/markdown/resolvers/mermaid.js +89 -0
- package/dist/markdown/resolvers/mermaidTheme.d.ts +27 -0
- package/dist/markdown/resolvers/mermaidTheme.js +113 -0
- package/dist/markdown/resolvers/resolver.d.ts +42 -0
- package/dist/markdown/resolvers/resolver.js +52 -0
- package/dist/markdown/slideParser.d.ts +14 -0
- package/dist/markdown/slideParser.js +196 -0
- package/dist/markdown/textTemplate.d.ts +15 -0
- package/dist/markdown/textTemplate.js +75 -0
- package/dist/markdown/types.d.ts +239 -0
- package/dist/markdown/types.js +40 -0
- package/package.json +41 -0
- package/syntax.md +291 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared shape / DOM helpers used by every fill primitive.
|
|
3
|
+
*
|
|
4
|
+
* Two layers live here. First the low-level slide-XML substrate: `Tag` names the
|
|
5
|
+
* DrawingML / PresentationML elements, plus node utilities (element collection,
|
|
6
|
+
* run text access, run and paragraph builders) and hyperlink relationship
|
|
7
|
+
* management. Second, the higher-level StyledParagraph-rebuild machinery shared
|
|
8
|
+
* by fillText and fillTable: harvest specimen (pPr, rPr) buckets grouped by
|
|
9
|
+
* (bullet kind, level), detach the specimens, then build fresh paragraphs
|
|
10
|
+
* cloning the appropriate bucket. No fill strategy lives here — this is the
|
|
11
|
+
* substrate the fill modules build on.
|
|
12
|
+
*/
|
|
13
|
+
// ── XML Tag Constants ──────────────────────────────────────────────────────────
|
|
14
|
+
/** DrawingML and PresentationML tag names used when manipulating slide XML. */
|
|
15
|
+
export const Tag = {
|
|
16
|
+
RUN: "a:r",
|
|
17
|
+
TEXT: "a:t",
|
|
18
|
+
PARAGRAPH: "a:p",
|
|
19
|
+
RUN_PROPS: "a:rPr",
|
|
20
|
+
PARA_PROPS: "a:pPr",
|
|
21
|
+
BULLET_CHAR: "a:buChar",
|
|
22
|
+
BULLET_AUTO: "a:buAutoNum",
|
|
23
|
+
BULLET_NONE: "a:buNone",
|
|
24
|
+
LINE_BREAK: "a:br",
|
|
25
|
+
END_PARA_RUN_PROPS: "a:endParaRPr",
|
|
26
|
+
OFFSET: "a:off",
|
|
27
|
+
EXTENT: "a:ext",
|
|
28
|
+
BLIP: "a:blip",
|
|
29
|
+
SRC_RECT: "a:srcRect",
|
|
30
|
+
BLIP_FILL: "p:blipFill",
|
|
31
|
+
SPACE_BEFORE: "a:spcBef",
|
|
32
|
+
HLINK_CLICK: "a:hlinkClick",
|
|
33
|
+
RELATIONSHIP: "Relationship",
|
|
34
|
+
TABLE: "a:tbl",
|
|
35
|
+
TABLE_ROW: "a:tr",
|
|
36
|
+
TABLE_CELL: "a:tc",
|
|
37
|
+
TX_BODY: "a:txBody",
|
|
38
|
+
SOLID_FILL: "a:solidFill",
|
|
39
|
+
SRGB_CLR: "a:srgbClr",
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* OOXML attribute names and fixed attribute values. Element names live in `Tag`;
|
|
43
|
+
* this is the same discipline for the attributes and enumerated values written
|
|
44
|
+
* onto them, so no bare string literal leaks into the fill code.
|
|
45
|
+
*/
|
|
46
|
+
export const Attr = {
|
|
47
|
+
// rPr marks
|
|
48
|
+
BOLD: "b",
|
|
49
|
+
ITALIC: "i",
|
|
50
|
+
STRIKE: "strike",
|
|
51
|
+
UNDERLINE: "u",
|
|
52
|
+
ON: "1",
|
|
53
|
+
STRIKE_SINGLE: "sngStrike",
|
|
54
|
+
UNDERLINE_SINGLE: "sng",
|
|
55
|
+
// color
|
|
56
|
+
VALUE: "val",
|
|
57
|
+
// relationships
|
|
58
|
+
ID: "Id",
|
|
59
|
+
TYPE: "Type",
|
|
60
|
+
TARGET: "Target",
|
|
61
|
+
TARGET_MODE: "TargetMode",
|
|
62
|
+
EXTERNAL: "External",
|
|
63
|
+
HLINK_ID: "r:id",
|
|
64
|
+
// paragraph
|
|
65
|
+
LEVEL: "lvl",
|
|
66
|
+
// geometry (offset / extent)
|
|
67
|
+
X: "x",
|
|
68
|
+
Y: "y",
|
|
69
|
+
CX: "cx",
|
|
70
|
+
CY: "cy",
|
|
71
|
+
// srcRect edges
|
|
72
|
+
LEFT: "l",
|
|
73
|
+
TOP: "t",
|
|
74
|
+
RIGHT: "r",
|
|
75
|
+
BOTTOM: "b",
|
|
76
|
+
// whitespace preservation
|
|
77
|
+
XML_SPACE: "xml:space",
|
|
78
|
+
PRESERVE: "preserve",
|
|
79
|
+
};
|
|
80
|
+
/** True for a non-null, non-array object — the shared prefix of every `isXFill`. */
|
|
81
|
+
export function isPlainObject(v) {
|
|
82
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
83
|
+
}
|
|
84
|
+
// ── DOM Helpers ────────────────────────────────────────────────────────────────
|
|
85
|
+
export function collectElements(parent, tagName) {
|
|
86
|
+
const out = [];
|
|
87
|
+
const live = parent.getElementsByTagName(tagName);
|
|
88
|
+
for (let i = 0; i < live.length; i++)
|
|
89
|
+
out.push(live[i]);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
export function childrenByTag(parent, tagName) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
95
|
+
const child = parent.childNodes[i];
|
|
96
|
+
if (child && child.nodeType === 1 && child.tagName === tagName)
|
|
97
|
+
out.push(child);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
export function detach(node) {
|
|
102
|
+
if (node?.parentNode)
|
|
103
|
+
node.parentNode.removeChild(node);
|
|
104
|
+
}
|
|
105
|
+
export function runText(run) {
|
|
106
|
+
const t = run.getElementsByTagName(Tag.TEXT)[0];
|
|
107
|
+
return t ? (t.textContent ?? "") : "";
|
|
108
|
+
}
|
|
109
|
+
export function setRunTextPreservingStyle(run, text) {
|
|
110
|
+
let t = run.getElementsByTagName(Tag.TEXT)[0];
|
|
111
|
+
if (!t) {
|
|
112
|
+
t = run.ownerDocument.createElement(Tag.TEXT);
|
|
113
|
+
run.appendChild(t);
|
|
114
|
+
}
|
|
115
|
+
if (text !== text.trim())
|
|
116
|
+
t.setAttribute(Attr.XML_SPACE, Attr.PRESERVE);
|
|
117
|
+
else
|
|
118
|
+
t.removeAttribute(Attr.XML_SPACE);
|
|
119
|
+
t.textContent = text;
|
|
120
|
+
}
|
|
121
|
+
export function leadingDecorativePrefix(text) {
|
|
122
|
+
const m = /^[\s\p{P}\p{S}]+/u.exec(text);
|
|
123
|
+
return m ? m[0] : "";
|
|
124
|
+
}
|
|
125
|
+
// ── Run/Paragraph Builders ────────────────────────────────────────────────────
|
|
126
|
+
export function buildRun(doc, cloneRPr, text) {
|
|
127
|
+
const r = doc.createElement(Tag.RUN);
|
|
128
|
+
if (cloneRPr)
|
|
129
|
+
r.appendChild(cloneRPr.cloneNode(true));
|
|
130
|
+
const t = doc.createElement(Tag.TEXT);
|
|
131
|
+
if (text !== text.trim())
|
|
132
|
+
t.setAttribute(Attr.XML_SPACE, Attr.PRESERVE);
|
|
133
|
+
t.textContent = text;
|
|
134
|
+
r.appendChild(t);
|
|
135
|
+
return r;
|
|
136
|
+
}
|
|
137
|
+
export function buildParagraph(doc, clonePPr, run) {
|
|
138
|
+
const p = doc.createElement(Tag.PARAGRAPH);
|
|
139
|
+
if (clonePPr)
|
|
140
|
+
p.appendChild(clonePPr.cloneNode(true));
|
|
141
|
+
p.appendChild(run);
|
|
142
|
+
return p;
|
|
143
|
+
}
|
|
144
|
+
// ── Relationship Management ──────────────────────────────────────────────────
|
|
145
|
+
export const HYPERLINK_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
|
146
|
+
export function addRelationship(relation, url) {
|
|
147
|
+
const existing = collectElements(relation, Tag.RELATIONSHIP);
|
|
148
|
+
let maxId = 0;
|
|
149
|
+
for (const rel of existing) {
|
|
150
|
+
const id = rel.getAttribute(Attr.ID);
|
|
151
|
+
const num = id ? parseInt(id.replace("rId", ""), 10) : 0;
|
|
152
|
+
if (num > maxId)
|
|
153
|
+
maxId = num;
|
|
154
|
+
}
|
|
155
|
+
const rId = `rId${maxId + 1}`;
|
|
156
|
+
const rel = relation.ownerDocument.createElement(Tag.RELATIONSHIP);
|
|
157
|
+
rel.setAttribute(Attr.ID, rId);
|
|
158
|
+
rel.setAttribute(Attr.TYPE, HYPERLINK_REL_TYPE);
|
|
159
|
+
rel.setAttribute(Attr.TARGET, url);
|
|
160
|
+
rel.setAttribute(Attr.TARGET_MODE, Attr.EXTERNAL);
|
|
161
|
+
relation.appendChild(rel);
|
|
162
|
+
return rId;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Replace all runs in a paragraph with a sequence of styled runs, cloning the
|
|
166
|
+
* first existing run's rPr as the template and layering per-run marks on top.
|
|
167
|
+
*
|
|
168
|
+
* Exported for tests; not part of the public engine surface (index.ts).
|
|
169
|
+
*/
|
|
170
|
+
export function setRichRuns(para, runs, relation) {
|
|
171
|
+
const existing = collectElements(para, Tag.RUN);
|
|
172
|
+
if (existing.length === 0)
|
|
173
|
+
return;
|
|
174
|
+
const tpl = existing[0];
|
|
175
|
+
const newNodes = [];
|
|
176
|
+
for (const run of runs) {
|
|
177
|
+
const clone = tpl.cloneNode(true);
|
|
178
|
+
const t = clone.getElementsByTagName(Tag.TEXT)[0];
|
|
179
|
+
if (t) {
|
|
180
|
+
t.textContent = run.text;
|
|
181
|
+
// The cloned template run may already carry xml:space — set or clear it to
|
|
182
|
+
// match the new text, mirroring setRunTextPreservingStyle.
|
|
183
|
+
if (run.text !== run.text.trim())
|
|
184
|
+
t.setAttribute(Attr.XML_SPACE, Attr.PRESERVE);
|
|
185
|
+
else
|
|
186
|
+
t.removeAttribute(Attr.XML_SPACE);
|
|
187
|
+
}
|
|
188
|
+
if (run.bold || run.italic || run.strikethrough || run.underline) {
|
|
189
|
+
let rPr = clone.getElementsByTagName(Tag.RUN_PROPS)[0];
|
|
190
|
+
if (!rPr) {
|
|
191
|
+
rPr = para.ownerDocument.createElement(Tag.RUN_PROPS);
|
|
192
|
+
clone.insertBefore(rPr, clone.firstChild);
|
|
193
|
+
}
|
|
194
|
+
if (run.bold)
|
|
195
|
+
rPr.setAttribute(Attr.BOLD, Attr.ON);
|
|
196
|
+
if (run.italic)
|
|
197
|
+
rPr.setAttribute(Attr.ITALIC, Attr.ON);
|
|
198
|
+
if (run.strikethrough)
|
|
199
|
+
rPr.setAttribute(Attr.STRIKE, Attr.STRIKE_SINGLE);
|
|
200
|
+
if (run.underline)
|
|
201
|
+
rPr.setAttribute(Attr.UNDERLINE, Attr.UNDERLINE_SINGLE);
|
|
202
|
+
}
|
|
203
|
+
if (run.color) {
|
|
204
|
+
let rPr = clone.getElementsByTagName(Tag.RUN_PROPS)[0];
|
|
205
|
+
if (!rPr) {
|
|
206
|
+
rPr = para.ownerDocument.createElement(Tag.RUN_PROPS);
|
|
207
|
+
clone.insertBefore(rPr, clone.firstChild);
|
|
208
|
+
}
|
|
209
|
+
const existingSolidFill = rPr.getElementsByTagName(Tag.SOLID_FILL)[0];
|
|
210
|
+
if (existingSolidFill)
|
|
211
|
+
rPr.removeChild(existingSolidFill);
|
|
212
|
+
const solidFill = para.ownerDocument.createElement(Tag.SOLID_FILL);
|
|
213
|
+
const srgbClr = para.ownerDocument.createElement(Tag.SRGB_CLR);
|
|
214
|
+
srgbClr.setAttribute(Attr.VALUE, run.color);
|
|
215
|
+
solidFill.appendChild(srgbClr);
|
|
216
|
+
rPr.appendChild(solidFill);
|
|
217
|
+
}
|
|
218
|
+
if (run.link && relation) {
|
|
219
|
+
let rPr = clone.getElementsByTagName(Tag.RUN_PROPS)[0];
|
|
220
|
+
if (!rPr) {
|
|
221
|
+
rPr = para.ownerDocument.createElement(Tag.RUN_PROPS);
|
|
222
|
+
clone.insertBefore(rPr, clone.firstChild);
|
|
223
|
+
}
|
|
224
|
+
const rId = addRelationship(relation, run.link);
|
|
225
|
+
const hlink = para.ownerDocument.createElement(Tag.HLINK_CLICK);
|
|
226
|
+
hlink.setAttribute(Attr.HLINK_ID, rId);
|
|
227
|
+
rPr.appendChild(hlink);
|
|
228
|
+
}
|
|
229
|
+
newNodes.push(clone);
|
|
230
|
+
}
|
|
231
|
+
for (const old of existing)
|
|
232
|
+
detach(old);
|
|
233
|
+
const endParaRPr = para.getElementsByTagName(Tag.END_PARA_RUN_PROPS)[0];
|
|
234
|
+
for (const nr of newNodes) {
|
|
235
|
+
if (endParaRPr)
|
|
236
|
+
para.insertBefore(nr, endParaRPr);
|
|
237
|
+
else
|
|
238
|
+
para.appendChild(nr);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function paragraphBulletKind(paragraph) {
|
|
242
|
+
const pPr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
|
|
243
|
+
if (!pPr)
|
|
244
|
+
return "inherit";
|
|
245
|
+
if (childrenByTag(pPr, Tag.BULLET_NONE).length > 0)
|
|
246
|
+
return "none";
|
|
247
|
+
if (childrenByTag(pPr, Tag.BULLET_CHAR).length > 0 || childrenByTag(pPr, Tag.BULLET_AUTO).length > 0) {
|
|
248
|
+
return "bullet";
|
|
249
|
+
}
|
|
250
|
+
return "inherit";
|
|
251
|
+
}
|
|
252
|
+
function paragraphLevel(paragraph) {
|
|
253
|
+
const ppr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
|
|
254
|
+
const lvl = ppr?.getAttribute(Attr.LEVEL);
|
|
255
|
+
return lvl ? Number(lvl) || 0 : 0;
|
|
256
|
+
}
|
|
257
|
+
function harvestStyles(specimen) {
|
|
258
|
+
const bullets = new Map();
|
|
259
|
+
let paras = null;
|
|
260
|
+
for (const p of specimen) {
|
|
261
|
+
const kind = paragraphBulletKind(p);
|
|
262
|
+
const lvl = paragraphLevel(p);
|
|
263
|
+
const firstRun = collectElements(p, Tag.RUN)[0];
|
|
264
|
+
const bucket = {
|
|
265
|
+
pPr: p.getElementsByTagName(Tag.PARA_PROPS)[0] ?? null,
|
|
266
|
+
rPr: firstRun ? (firstRun.getElementsByTagName(Tag.RUN_PROPS)[0] ?? null) : null,
|
|
267
|
+
};
|
|
268
|
+
if (kind === "bullet") {
|
|
269
|
+
if (!bullets.has(lvl))
|
|
270
|
+
bullets.set(lvl, bucket);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
if (paras == null)
|
|
274
|
+
paras = bucket;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { bullets, paras };
|
|
278
|
+
}
|
|
279
|
+
function specimenBulletPara(specimen, level) {
|
|
280
|
+
for (const p of specimen) {
|
|
281
|
+
if (paragraphBulletKind(p) === "bullet" && paragraphLevel(p) === level)
|
|
282
|
+
return p;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
function maybeOverrideLevel(paragraph, level) {
|
|
287
|
+
if (level == null)
|
|
288
|
+
return;
|
|
289
|
+
const ppr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
|
|
290
|
+
if (!ppr)
|
|
291
|
+
return;
|
|
292
|
+
if (level === 0)
|
|
293
|
+
ppr.removeAttribute(Attr.LEVEL);
|
|
294
|
+
else
|
|
295
|
+
ppr.setAttribute(Attr.LEVEL, String(level));
|
|
296
|
+
}
|
|
297
|
+
// ── Rebuild strategy (shared by fillText and fillTable) ──────────────────────
|
|
298
|
+
export function rebuildParagraphs(shape, paragraphs, startIndex, relation, shapeName = "") {
|
|
299
|
+
const allParas = collectElements(shape, Tag.PARAGRAPH);
|
|
300
|
+
if (allParas.length === 0) {
|
|
301
|
+
throw new Error(`Shape "${shapeName}": has no paragraphs to rebuild from (need at least one specimen <a:p>).`);
|
|
302
|
+
}
|
|
303
|
+
const txBody = allParas[0].parentNode;
|
|
304
|
+
const specimen = allParas.slice(startIndex);
|
|
305
|
+
if (specimen.length === 0) {
|
|
306
|
+
throw new Error(`Shape "${shapeName}": startAt ${startIndex} is past the last paragraph (shape has ${allParas.length}).`);
|
|
307
|
+
}
|
|
308
|
+
const { bullets, paras } = harvestStyles(specimen);
|
|
309
|
+
const maxBulletLevel = bullets.size > 0 ? Math.max(...bullets.keys()) : -1;
|
|
310
|
+
const pickBullet = (lvl) => {
|
|
311
|
+
if (bullets.has(lvl))
|
|
312
|
+
return { bucket: bullets.get(lvl), effectiveLvl: lvl };
|
|
313
|
+
if (maxBulletLevel >= 0)
|
|
314
|
+
return { bucket: bullets.get(maxBulletLevel), effectiveLvl: maxBulletLevel };
|
|
315
|
+
return { bucket: null, effectiveLvl: lvl };
|
|
316
|
+
};
|
|
317
|
+
const plainBucket = paras ?? bullets.get(0) ?? null;
|
|
318
|
+
const firstBulletKey = bullets.has(0) ? 0 : maxBulletLevel;
|
|
319
|
+
const firstBullet = firstBulletKey >= 0 ? specimenBulletPara(specimen, firstBulletKey) : null;
|
|
320
|
+
const transitionSpcBef = firstBullet?.getElementsByTagName(Tag.PARA_PROPS)[0]?.getElementsByTagName(Tag.SPACE_BEFORE)[0] ?? null;
|
|
321
|
+
const applyTransitionSpacing = (paragraph) => {
|
|
322
|
+
if (!transitionSpcBef)
|
|
323
|
+
return;
|
|
324
|
+
const ppr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
|
|
325
|
+
if (!ppr)
|
|
326
|
+
return;
|
|
327
|
+
const old = ppr.getElementsByTagName(Tag.SPACE_BEFORE)[0];
|
|
328
|
+
if (old)
|
|
329
|
+
ppr.removeChild(old);
|
|
330
|
+
ppr.appendChild(transitionSpcBef.cloneNode(true));
|
|
331
|
+
};
|
|
332
|
+
for (const p of specimen)
|
|
333
|
+
detach(p);
|
|
334
|
+
const doc = shape.ownerDocument;
|
|
335
|
+
let prevWasBullet = false;
|
|
336
|
+
for (const para of paragraphs) {
|
|
337
|
+
if (!para.runs || para.runs.length === 0)
|
|
338
|
+
continue;
|
|
339
|
+
const isBullet = para.bullet !== undefined;
|
|
340
|
+
const inLevel = para.bullet?.level ?? 0;
|
|
341
|
+
const { bucket, effectiveLvl } = isBullet ? pickBullet(inLevel) : { bucket: plainBucket, effectiveLvl: 0 };
|
|
342
|
+
// Skip fully-empty text (matches previous fillText behavior).
|
|
343
|
+
if (para.runs.length === 1 && !para.runs[0].text)
|
|
344
|
+
continue;
|
|
345
|
+
const seedRun = buildRun(doc, bucket?.rPr ?? null, "");
|
|
346
|
+
const newPara = buildParagraph(doc, bucket?.pPr ?? null, seedRun);
|
|
347
|
+
maybeOverrideLevel(newPara, isBullet ? effectiveLvl : null);
|
|
348
|
+
if (!isBullet && prevWasBullet)
|
|
349
|
+
applyTransitionSpacing(newPara);
|
|
350
|
+
setRichRuns(newPara, para.runs, relation);
|
|
351
|
+
txBody.appendChild(newPara);
|
|
352
|
+
prevWasBullet = isBullet;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Filler` strategy registry — one plain-object strategy per SlotType, each
|
|
3
|
+
* pairing a value discriminator with a slide-level fill. The record key IS the
|
|
4
|
+
* slot type, so a strategy carries no redundant `type` field. `generate()`
|
|
5
|
+
* consults `FILLERS[slot.type]` once per (slot, value): `matches` validates the
|
|
6
|
+
* value shape, then `fill` applies it to the slide.
|
|
7
|
+
*
|
|
8
|
+
* Element-level geometry lives in the `fillX` primitives; slide-level concerns
|
|
9
|
+
* (media pre-swap for images, relation access for body hyperlinks, column
|
|
10
|
+
* validation for tables) live in the strategy wrappers here.
|
|
11
|
+
*/
|
|
12
|
+
import { type Slot, SlotType } from "../types.js";
|
|
13
|
+
export type FillContext = {
|
|
14
|
+
layoutName: string;
|
|
15
|
+
};
|
|
16
|
+
export interface Filler<T> {
|
|
17
|
+
matches(v: unknown): v is T;
|
|
18
|
+
/** Human name for the mismatch error — no magic string at the throw site. */
|
|
19
|
+
label: string;
|
|
20
|
+
fill(slide: any, slot: Slot, value: T, ctx: FillContext): void;
|
|
21
|
+
}
|
|
22
|
+
export declare const FILLERS: Record<SlotType, Filler<any>>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Filler` strategy registry — one plain-object strategy per SlotType, each
|
|
3
|
+
* pairing a value discriminator with a slide-level fill. The record key IS the
|
|
4
|
+
* slot type, so a strategy carries no redundant `type` field. `generate()`
|
|
5
|
+
* consults `FILLERS[slot.type]` once per (slot, value): `matches` validates the
|
|
6
|
+
* value shape, then `fill` applies it to the slide.
|
|
7
|
+
*
|
|
8
|
+
* Element-level geometry lives in the `fillX` primitives; slide-level concerns
|
|
9
|
+
* (media pre-swap for images, relation access for body hyperlinks, column
|
|
10
|
+
* validation for tables) live in the strategy wrappers here.
|
|
11
|
+
*/
|
|
12
|
+
import { basename } from "node:path";
|
|
13
|
+
import { ModifyImageHelper } from "pptx-automizer";
|
|
14
|
+
import { SlotType } from "../types.js";
|
|
15
|
+
import { fillImage, isImageFill } from "./image.js";
|
|
16
|
+
import { fillTable, isTableFill } from "./table.js";
|
|
17
|
+
import { fillTemplate, isTemplateFill } from "./template.js";
|
|
18
|
+
import { fillText, isTextFill } from "./text.js";
|
|
19
|
+
export const FILLERS = {
|
|
20
|
+
[SlotType.Template]: {
|
|
21
|
+
matches: isTemplateFill,
|
|
22
|
+
label: "TemplateFill",
|
|
23
|
+
fill: (slide, slot, v) => slide.modifyElement(slot.shapeName, [(el) => fillTemplate(el, v, slot.shapeName)]),
|
|
24
|
+
},
|
|
25
|
+
[SlotType.Text]: {
|
|
26
|
+
matches: isTextFill,
|
|
27
|
+
label: "TextFill",
|
|
28
|
+
fill: (slide, slot, v) => {
|
|
29
|
+
const startAt = slot.startAt ?? 0;
|
|
30
|
+
slide.modifyElement(slot.shapeName, [
|
|
31
|
+
(el, relation) => fillText(el, v, { startAt, relation, shapeName: slot.shapeName }),
|
|
32
|
+
]);
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
[SlotType.Table]: {
|
|
36
|
+
matches: isTableFill,
|
|
37
|
+
label: "TableFill",
|
|
38
|
+
fill: (slide, slot, v, { layoutName }) => {
|
|
39
|
+
if (slot.columns !== undefined && v.headers.length !== slot.columns) {
|
|
40
|
+
throw new Error(`Layout "${layoutName}" slot "${slot.key}": table has ${v.headers.length} columns, template expects ${slot.columns}`);
|
|
41
|
+
}
|
|
42
|
+
slide.modifyElement(slot.shapeName, [(el) => fillTable(el, v, slot.shapeName)]);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
[SlotType.Image]: {
|
|
46
|
+
matches: isImageFill,
|
|
47
|
+
label: "ImageFill",
|
|
48
|
+
fill: (slide, slot, v) => slide.modifyElement(slot.shapeName, [
|
|
49
|
+
ModifyImageHelper.setRelationTarget(basename(v.path)),
|
|
50
|
+
(el) => fillImage(el, v, slot.shapeName),
|
|
51
|
+
]),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image fill — element-level picture geometry only. The media swap (pointing the
|
|
3
|
+
* blip relationship at the new file) is a slide-level modifier in the ImageFiller
|
|
4
|
+
* (`fillers/filler.ts`); this module just adjusts the frame for the fit mode.
|
|
5
|
+
*/
|
|
6
|
+
import { type ImageFill } from "../types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Adjust a picture shape's geometry for the chosen fit mode:
|
|
9
|
+
* - cover: writes `<a:srcRect>` insets (units: 1/100,000%) so the image fills
|
|
10
|
+
* the frame with the overflowing axis center-cropped.
|
|
11
|
+
* - contain: shrinks the picture's frame to the image's aspect-ratio dimensions
|
|
12
|
+
* and re-centers within the original frame bounds.
|
|
13
|
+
*
|
|
14
|
+
* `image.path` is assumed absolute — the compiler / caller resolves it before the
|
|
15
|
+
* ImageFill reaches the engine.
|
|
16
|
+
*/
|
|
17
|
+
export declare function fillImage(shape: any, image: ImageFill, shapeName?: string): void;
|
|
18
|
+
/** Discriminator for ImageFill values. */
|
|
19
|
+
export declare function isImageFill(v: unknown): v is ImageFill;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image fill — element-level picture geometry only. The media swap (pointing the
|
|
3
|
+
* blip relationship at the new file) is a slide-level modifier in the ImageFiller
|
|
4
|
+
* (`fillers/filler.ts`); this module just adjusts the frame for the fit mode.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { imageSize } from "image-size";
|
|
8
|
+
import { Attr, isPlainObject, Tag } from "../dom.js";
|
|
9
|
+
import { FitMode, SlotType } from "../types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Adjust a picture shape's geometry for the chosen fit mode:
|
|
12
|
+
* - cover: writes `<a:srcRect>` insets (units: 1/100,000%) so the image fills
|
|
13
|
+
* the frame with the overflowing axis center-cropped.
|
|
14
|
+
* - contain: shrinks the picture's frame to the image's aspect-ratio dimensions
|
|
15
|
+
* and re-centers within the original frame bounds.
|
|
16
|
+
*
|
|
17
|
+
* `image.path` is assumed absolute — the compiler / caller resolves it before the
|
|
18
|
+
* ImageFill reaches the engine.
|
|
19
|
+
*/
|
|
20
|
+
export function fillImage(shape, image, shapeName = "") {
|
|
21
|
+
const dims = imageSize(new Uint8Array(readFileSync(image.path)));
|
|
22
|
+
if (!dims.width || !dims.height) {
|
|
23
|
+
throw new Error(`Image shape "${shapeName}": could not read image dimensions from "${image.path}".`);
|
|
24
|
+
}
|
|
25
|
+
const off = shape.getElementsByTagName(Tag.OFFSET)[0];
|
|
26
|
+
const ext = shape.getElementsByTagName(Tag.EXTENT)[0];
|
|
27
|
+
const blipFill = shape.getElementsByTagName(Tag.BLIP_FILL)[0];
|
|
28
|
+
if (!off || !ext || !blipFill) {
|
|
29
|
+
throw new Error(`Image shape "${shapeName}": is not a picture (missing <a:off>, <a:ext>, or <p:blipFill>).`);
|
|
30
|
+
}
|
|
31
|
+
const frame = {
|
|
32
|
+
x: Number(off.getAttribute(Attr.X)),
|
|
33
|
+
y: Number(off.getAttribute(Attr.Y)),
|
|
34
|
+
w: Number(ext.getAttribute(Attr.CX)),
|
|
35
|
+
h: Number(ext.getAttribute(Attr.CY)),
|
|
36
|
+
};
|
|
37
|
+
const geom = computeFit(frame, dims.width, dims.height, image.fit);
|
|
38
|
+
if (geom.kind === "crop") {
|
|
39
|
+
// cover: symmetric crop on the overflowing axis, written as srcRect insets.
|
|
40
|
+
applySrcRect(shape, blipFill, geom.left, geom.top, geom.left, geom.top);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// contain: drop any inherited crop, then resize + re-center the frame itself.
|
|
44
|
+
applySrcRect(shape, blipFill, 0, 0, 0, 0);
|
|
45
|
+
ext.setAttribute(Attr.CX, String(geom.cx));
|
|
46
|
+
ext.setAttribute(Attr.CY, String(geom.cy));
|
|
47
|
+
off.setAttribute(Attr.X, String(geom.x));
|
|
48
|
+
off.setAttribute(Attr.Y, String(geom.y));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Pure fit geometry — no DOM, so it is trivially unit-testable in isolation.
|
|
52
|
+
* Given the picture frame (EMU) and the source image's pixel size: cover scales
|
|
53
|
+
* up until both axes are covered and crops the overflow; contain scales down
|
|
54
|
+
* until the whole image fits, then re-centres the shrunken frame.
|
|
55
|
+
*/
|
|
56
|
+
function computeFit(frame, imgW, imgH, fit) {
|
|
57
|
+
const fitX = frame.w / imgW;
|
|
58
|
+
const fitY = frame.h / imgH;
|
|
59
|
+
const inset = (fraction) => Math.round(fraction * 100000); // <a:srcRect> unit: 1/100,000%
|
|
60
|
+
if (fit === FitMode.Cover) {
|
|
61
|
+
const scale = Math.max(fitX, fitY);
|
|
62
|
+
const shownW = imgW * scale;
|
|
63
|
+
const shownH = imgH * scale;
|
|
64
|
+
return {
|
|
65
|
+
kind: "crop",
|
|
66
|
+
left: shownW > frame.w ? inset((1 - frame.w / shownW) / 2) : 0,
|
|
67
|
+
top: shownH > frame.h ? inset((1 - frame.h / shownH) / 2) : 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const scale = Math.min(fitX, fitY);
|
|
71
|
+
const cx = Math.round(imgW * scale);
|
|
72
|
+
const cy = Math.round(imgH * scale);
|
|
73
|
+
return {
|
|
74
|
+
kind: "frame",
|
|
75
|
+
x: Math.round(frame.x + (frame.w - cx) / 2),
|
|
76
|
+
y: Math.round(frame.y + (frame.h - cy) / 2),
|
|
77
|
+
cx,
|
|
78
|
+
cy,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Write `<a:srcRect>` crop insets (left/top/right/bottom, in 1/100,000%),
|
|
83
|
+
* creating the element right after `<a:blip>` when the picture has none yet.
|
|
84
|
+
*/
|
|
85
|
+
function applySrcRect(shape, blipFill, l, t, r, b) {
|
|
86
|
+
let srcRect = blipFill.getElementsByTagName(Tag.SRC_RECT)[0];
|
|
87
|
+
if (!srcRect) {
|
|
88
|
+
srcRect = shape.ownerDocument.createElement(Tag.SRC_RECT);
|
|
89
|
+
const blip = blipFill.getElementsByTagName(Tag.BLIP)[0];
|
|
90
|
+
if (blip?.nextSibling)
|
|
91
|
+
blipFill.insertBefore(srcRect, blip.nextSibling);
|
|
92
|
+
else
|
|
93
|
+
blipFill.appendChild(srcRect);
|
|
94
|
+
}
|
|
95
|
+
srcRect.setAttribute(Attr.LEFT, String(l));
|
|
96
|
+
srcRect.setAttribute(Attr.TOP, String(t));
|
|
97
|
+
srcRect.setAttribute(Attr.RIGHT, String(r));
|
|
98
|
+
srcRect.setAttribute(Attr.BOTTOM, String(b));
|
|
99
|
+
}
|
|
100
|
+
/** Discriminator for ImageFill values. */
|
|
101
|
+
export function isImageFill(v) {
|
|
102
|
+
return (isPlainObject(v) &&
|
|
103
|
+
v.type === SlotType.Image &&
|
|
104
|
+
typeof v.path === "string");
|
|
105
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table fill — clones specimen rows in the template's `<a:tbl>` (row 0 header,
|
|
3
|
+
* row 1 data, optional row 2 zebra) and fills each cell's first paragraph with
|
|
4
|
+
* the corresponding StyledParagraph. Row cloning must stay engine-side because
|
|
5
|
+
* it needs pptx-automizer DOM access.
|
|
6
|
+
*/
|
|
7
|
+
import type { TableFill } from "../types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Fill a table shape by cloning specimen rows.
|
|
10
|
+
*
|
|
11
|
+
* Row layout in the template's `<a:tbl>`:
|
|
12
|
+
* - Row 0: header specimen
|
|
13
|
+
* - Row 1: data specimen
|
|
14
|
+
* - Row 2 (optional): alternating-data specimen (zebra striping)
|
|
15
|
+
*
|
|
16
|
+
* Each cell's first paragraph is rebuilt from the corresponding
|
|
17
|
+
* StyledParagraph, so tables inherit rich-run/bullet support for free.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fillTable(shape: any, table: TableFill, shapeName?: string): void;
|
|
20
|
+
/** Discriminator for TableFill values. */
|
|
21
|
+
export declare function isTableFill(v: unknown): v is TableFill;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table fill — clones specimen rows in the template's `<a:tbl>` (row 0 header,
|
|
3
|
+
* row 1 data, optional row 2 zebra) and fills each cell's first paragraph with
|
|
4
|
+
* the corresponding StyledParagraph. Row cloning must stay engine-side because
|
|
5
|
+
* it needs pptx-automizer DOM access.
|
|
6
|
+
*/
|
|
7
|
+
import { collectElements, isPlainObject, rebuildParagraphs, Tag } from "../dom.js";
|
|
8
|
+
/**
|
|
9
|
+
* Fill a table shape by cloning specimen rows.
|
|
10
|
+
*
|
|
11
|
+
* Row layout in the template's `<a:tbl>`:
|
|
12
|
+
* - Row 0: header specimen
|
|
13
|
+
* - Row 1: data specimen
|
|
14
|
+
* - Row 2 (optional): alternating-data specimen (zebra striping)
|
|
15
|
+
*
|
|
16
|
+
* Each cell's first paragraph is rebuilt from the corresponding
|
|
17
|
+
* StyledParagraph, so tables inherit rich-run/bullet support for free.
|
|
18
|
+
*/
|
|
19
|
+
export function fillTable(shape, table, shapeName = "") {
|
|
20
|
+
const tbl = shape.getElementsByTagName(Tag.TABLE)[0];
|
|
21
|
+
if (!tbl) {
|
|
22
|
+
throw new Error(`Table shape "${shapeName}": has no <a:tbl> element (is it actually a table?).`);
|
|
23
|
+
}
|
|
24
|
+
const rows = collectElements(tbl, Tag.TABLE_ROW);
|
|
25
|
+
if (rows.length < 2) {
|
|
26
|
+
throw new Error(`fillTable: template table has ${rows.length} row(s), need at least 2 (header + data specimen)`);
|
|
27
|
+
}
|
|
28
|
+
const headerTpl = rows[0];
|
|
29
|
+
// Only rows 0-2 are specimens (header, data, optional zebra); any further
|
|
30
|
+
// template rows are intentionally dropped — the specimen rows are re-cloned
|
|
31
|
+
// per data row below.
|
|
32
|
+
const dataTpls = rows.length > 2 ? [rows[1], rows[2]] : [rows[1]];
|
|
33
|
+
const fillRow = (tpl, cells) => {
|
|
34
|
+
const clone = tpl.cloneNode(true);
|
|
35
|
+
const tcs = collectElements(clone, Tag.TABLE_CELL);
|
|
36
|
+
// Fill min(cells, template cells): extra data columns beyond the template's
|
|
37
|
+
// cell count are intentionally ignored (column count is opt-in via
|
|
38
|
+
// slot.columns, enforced in the Table filler).
|
|
39
|
+
for (let i = 0; i < cells.length && i < tcs.length; i++) {
|
|
40
|
+
const txBody = tcs[i].getElementsByTagName(Tag.TX_BODY)[0] ?? tcs[i];
|
|
41
|
+
rebuildParagraphs(txBody, [cells[i]], 0, undefined, shapeName);
|
|
42
|
+
}
|
|
43
|
+
return clone;
|
|
44
|
+
};
|
|
45
|
+
const built = [];
|
|
46
|
+
built.push(fillRow(headerTpl, table.headers));
|
|
47
|
+
for (let r = 0; r < table.rows.length; r++) {
|
|
48
|
+
built.push(fillRow(dataTpls[r % dataTpls.length], table.rows[r]));
|
|
49
|
+
}
|
|
50
|
+
for (const row of rows)
|
|
51
|
+
tbl.removeChild(row);
|
|
52
|
+
for (const row of built)
|
|
53
|
+
tbl.appendChild(row);
|
|
54
|
+
}
|
|
55
|
+
/** Discriminator for TableFill values. */
|
|
56
|
+
export function isTableFill(v) {
|
|
57
|
+
return (isPlainObject(v) &&
|
|
58
|
+
"headers" in v &&
|
|
59
|
+
"rows" in v &&
|
|
60
|
+
Array.isArray(v.headers) &&
|
|
61
|
+
Array.isArray(v.rows));
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-template fill — fills each visual line in place from its template
|
|
3
|
+
* segments. A visual line starts at either a paragraph boundary (`<a:p>`) or a
|
|
4
|
+
* soft line break (`<a:br/>`); template line i fills visual line i, 1:1. Styles
|
|
5
|
+
* are never read or changed: coalesce same-style runs, then a uniform line takes
|
|
6
|
+
* the substituted text directly, while a multi-style line is matched against the
|
|
7
|
+
* sample to place each variable's value in the run carrying its style.
|
|
8
|
+
*/
|
|
9
|
+
import type { TemplateFill } from "../types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Fill a text shape from its template segments — styles are never read or
|
|
12
|
+
* changed. Template line i fills the shape's visual line i, 1:1, via
|
|
13
|
+
* `fillLineFromSegments`: coalesce same-style runs, then a uniform line takes the
|
|
14
|
+
* substituted text directly, while a multi-style line is matched against the
|
|
15
|
+
* sample to place each variable's value in the run carrying its style. A template
|
|
16
|
+
* with more lines than the shape has visual lines is an authoring error (throw);
|
|
17
|
+
* fewer lines fill the covered lines and leave the rest untouched.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fillTemplate(shape: any, fill: TemplateFill, shapeName?: string): void;
|
|
20
|
+
/** Discriminator for TemplateFill values. */
|
|
21
|
+
export declare function isTemplateFill(v: unknown): v is TemplateFill;
|
|
22
|
+
/**
|
|
23
|
+
* Merge adjacent same-style runs across a whole paragraph. Thin wrapper over
|
|
24
|
+
* `coalesceRunList` operating on the paragraph's direct `<a:r>` children.
|
|
25
|
+
* Exported for tests.
|
|
26
|
+
*/
|
|
27
|
+
export declare function coalesceSameStyleRuns(para: any): void;
|