@lutrin/core 1.0.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 +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Font embedding in the .pptx (post-processing).
|
|
3
|
+
*
|
|
4
|
+
* PptxGenJS cannot embed fonts, but the OOXML format allows it: each TTF
|
|
5
|
+
* variant becomes a `ppt/fonts/fontN.fntdata` part (raw TTF data, not
|
|
6
|
+
* obfuscated — this is what PowerPoint itself writes for a .pptx),
|
|
7
|
+
* referenced from `<p:embeddedFontLst>` in ppt/presentation.xml. So we reopen
|
|
8
|
+
* the zip produced by PptxGenJS and inject into it the variants the active
|
|
9
|
+
* theme provides (FONT_FILES).
|
|
10
|
+
*
|
|
11
|
+
* Not every font grants the right: the OS/2 table carries an `fsType` field
|
|
12
|
+
* saying what its foundry allows, and "Restricted License embedding" means
|
|
13
|
+
* no. Embedding anyway means redistributing a licensed font inside a file
|
|
14
|
+
* that circulates — the .pptx leaves by email, there is no taking it back.
|
|
15
|
+
* The permission is therefore CHECKED here (readFsType), font by font, and a
|
|
16
|
+
* refusal is reported to the author rather than left to guesswork: the kit may
|
|
17
|
+
* well be theirs, the font rarely is.
|
|
18
|
+
*
|
|
19
|
+
* PowerPoint (Windows/macOS/web) then loads the font even when it is not
|
|
20
|
+
* installed on the machine; Keynote and LibreOffice ignore embedded fonts
|
|
21
|
+
* and keep the documented Arial fallback.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import JSZip from 'jszip';
|
|
27
|
+
import { FONTS, FONT_FILES } from '../deck/tokens.mjs';
|
|
28
|
+
|
|
29
|
+
/** Variants of the FONTS.body family; the label is the OOXML element targeted.
|
|
30
|
+
* The TTF paths come from FONT_FILES (themable) — read at call time. */
|
|
31
|
+
const VARIANTS = [
|
|
32
|
+
{ element: 'p:regular', key: 'regular' },
|
|
33
|
+
{ element: 'p:bold', key: 'bold' },
|
|
34
|
+
{ element: 'p:italic', key: 'italic' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/font';
|
|
38
|
+
|
|
39
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Embedding permission: the fsType field of the OS/2 table
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Offset of `fsType` (big-endian uint16) within the OS/2 table — just after
|
|
46
|
+
* version, xAvgCharWidth, usWeightClass and usWidthClass. */
|
|
47
|
+
const FSTYPE_OFFSET = 8;
|
|
48
|
+
|
|
49
|
+
/** The four license levels fit in bits 0-3; the rest of the field (no
|
|
50
|
+
* subsetting, bitmap only) does not concern us, we embed the whole file
|
|
51
|
+
* as is. */
|
|
52
|
+
const FSTYPE_RESTRICTED = 0x0002; // "Restricted License embedding": no
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* fsType of an sfnt font whose header (offset table) starts at `base`.
|
|
56
|
+
* The header: version on 4 bytes, numTables on 2, then three binary-search
|
|
57
|
+
* fields we ignore; the table records follow at +12, 16 bytes each (tag,
|
|
58
|
+
* checksum, offset, length).
|
|
59
|
+
* @returns {number|null} null if the font has no OS/2 table
|
|
60
|
+
*/
|
|
61
|
+
function sfntFsType(buf, base) {
|
|
62
|
+
const numTables = buf.readUInt16BE(base + 4);
|
|
63
|
+
for (let k = 0; k < numTables; k++) {
|
|
64
|
+
const rec = base + 12 + k * 16;
|
|
65
|
+
if (buf.toString('latin1', rec, rec + 4) !== 'OS/2') continue;
|
|
66
|
+
return buf.readUInt16BE(buf.readUInt32BE(rec + 8) + FSTYPE_OFFSET);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reads the embedding permission declared by a font file.
|
|
73
|
+
*
|
|
74
|
+
* 0 = installable, 2 = Restricted License embedding, 4 = preview & print,
|
|
75
|
+
* 8 = editable embedding. Only 2 shuts the door on us.
|
|
76
|
+
*
|
|
77
|
+
* Collections (.ttc): the file carries several fonts and we embed it WHOLE.
|
|
78
|
+
* The value kept is that of the first font — it is the one the variant
|
|
79
|
+
* designates — unless any one of the fonts in the collection is Restricted:
|
|
80
|
+
* that refusal wins, since it would go into the deliverable along with the
|
|
81
|
+
* others.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} file path to a .ttf, .otf or .ttc
|
|
84
|
+
* @returns {number|null} null if the file could not be read or has a
|
|
85
|
+
* structure we do not know how to read — the caller warns without
|
|
86
|
+
* blocking: taking the wrong side here would mean refusing to export
|
|
87
|
+
* a deck over a font nobody disputes.
|
|
88
|
+
*/
|
|
89
|
+
export function readFsType(file) {
|
|
90
|
+
let buf;
|
|
91
|
+
try {
|
|
92
|
+
buf = fs.readFileSync(file);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
if (buf.toString('latin1', 0, 4) !== 'ttcf') return sfntFsType(buf, 0);
|
|
98
|
+
const numFonts = buf.readUInt32BE(8);
|
|
99
|
+
const all = [];
|
|
100
|
+
for (let k = 0; k < numFonts; k++) all.push(sfntFsType(buf, buf.readUInt32BE(12 + k * 4)));
|
|
101
|
+
if (all.some((v) => v != null && v & FSTYPE_RESTRICTED)) return FSTYPE_RESTRICTED;
|
|
102
|
+
return all[0] ?? null;
|
|
103
|
+
} catch {
|
|
104
|
+
// offset out of bounds, truncated table: malformed font file
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Embeds the brand font into a .pptx already written to disk.
|
|
111
|
+
* Idempotent; touches nothing if the TTFs are absent.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} pptxPath path to the .pptx to modify in place
|
|
114
|
+
* @returns {Promise<{count:number, warnings:string[]}>} embedded variants
|
|
115
|
+
* (count 0 if the TTFs are absent — documented Arial fallback); any
|
|
116
|
+
* bail-out on an unexpected structure is reported as a warning.
|
|
117
|
+
*/
|
|
118
|
+
export async function embedFonts(pptxPath) {
|
|
119
|
+
const warnings = [];
|
|
120
|
+
const present = VARIANTS.map((v) => ({ ...v, file: FONT_FILES[v.key] })).filter(
|
|
121
|
+
(v) => typeof v.file === 'string' && fs.existsSync(v.file),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// license filter: what is refused does not go into the deliverable, and the
|
|
125
|
+
// author learns it here rather than by receiving a cease-and-desist
|
|
126
|
+
const variants = present.filter((v) => {
|
|
127
|
+
const fsType = readFsType(v.file);
|
|
128
|
+
if (fsType == null) {
|
|
129
|
+
warnings.push(
|
|
130
|
+
`Font "${path.basename(v.file)}" (${v.key}): embedding permission could not be read (OS/2 table absent or malformed file) — embedded anyway, to be checked with its foundry.`,
|
|
131
|
+
);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (fsType & FSTYPE_RESTRICTED) {
|
|
135
|
+
warnings.push(
|
|
136
|
+
`Font "${path.basename(v.file)}" (${v.key}): its foundry forbids embedding (fsType ${fsType}, "Restricted License embedding") — not embedded, machines without this font will read the deck with the fallback font.`,
|
|
137
|
+
);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!variants.length) return { count: 0, warnings };
|
|
144
|
+
|
|
145
|
+
const zip = await JSZip.loadAsync(fs.readFileSync(pptxPath));
|
|
146
|
+
const presFile = zip.file('ppt/presentation.xml');
|
|
147
|
+
const relsFile = zip.file('ppt/_rels/presentation.xml.rels');
|
|
148
|
+
const typesFile = zip.file('[Content_Types].xml');
|
|
149
|
+
if (!presFile || !relsFile || !typesFile) {
|
|
150
|
+
warnings.push('.pptx without the expected presentation.xml/rels — fonts not embedded');
|
|
151
|
+
return { count: 0, warnings };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let pres = await presFile.async('string');
|
|
155
|
+
if (pres.includes('<p:embeddedFontLst>')) return { count: variants.length, warnings }; // already done
|
|
156
|
+
|
|
157
|
+
// Binary parts: ppt/fonts/fontN.fntdata (raw TTF)
|
|
158
|
+
variants.forEach((v, k) => {
|
|
159
|
+
zip.file(`ppt/fonts/font${k + 1}.fntdata`, fs.readFileSync(v.file));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// [Content_Types].xml: content type of the fntdata parts
|
|
163
|
+
let types = await typesFile.async('string');
|
|
164
|
+
if (!types.includes('Extension="fntdata"')) {
|
|
165
|
+
types = types.replace(
|
|
166
|
+
'</Types>',
|
|
167
|
+
'<Default Extension="fntdata" ContentType="application/x-fontdata"/></Types>',
|
|
168
|
+
);
|
|
169
|
+
zip.file('[Content_Types].xml', types);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Relationships: a fresh rId per variant
|
|
173
|
+
const rels = await relsFile.async('string');
|
|
174
|
+
const maxId = Math.max(0, ...[...rels.matchAll(/Id="rId(\d+)"/g)].map((m) => Number(m[1])));
|
|
175
|
+
const relXml = variants
|
|
176
|
+
.map(
|
|
177
|
+
(v, k) =>
|
|
178
|
+
`<Relationship Id="rId${maxId + 1 + k}" Type="${REL_TYPE}" Target="fonts/font${k + 1}.fntdata"/>`,
|
|
179
|
+
)
|
|
180
|
+
.join('');
|
|
181
|
+
zip.file(
|
|
182
|
+
'ppt/_rels/presentation.xml.rels',
|
|
183
|
+
rels.replace('</Relationships>', `${relXml}</Relationships>`),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// presentation.xml: embedTrueTypeFonts attribute + list of the embedded
|
|
187
|
+
// fonts (position per the CT_Presentation schema: after <p:notesSz>).
|
|
188
|
+
if (!/embedTrueTypeFonts=/.test(pres)) {
|
|
189
|
+
pres = pres.replace('<p:presentation ', '<p:presentation embedTrueTypeFonts="1" ');
|
|
190
|
+
}
|
|
191
|
+
const fontRefs = variants.map((v, k) => `<${v.element} r:id="rId${maxId + 1 + k}"/>`).join('');
|
|
192
|
+
const fontLst = `<p:embeddedFontLst><p:embeddedFont><p:font typeface="${esc(FONTS.body)}"/>${fontRefs}</p:embeddedFont></p:embeddedFontLst>`;
|
|
193
|
+
const patched = pres.replace(/(<p:notesSz[^>]*\/>|<\/p:notesSz>)/, `$1${fontLst}`);
|
|
194
|
+
if (patched === pres) {
|
|
195
|
+
// unexpected structure: break nothing, but say so
|
|
196
|
+
warnings.push('presentation.xml without the expected <p:notesSz> — fonts not embedded');
|
|
197
|
+
return { count: 0, warnings };
|
|
198
|
+
}
|
|
199
|
+
zip.file('ppt/presentation.xml', patched);
|
|
200
|
+
|
|
201
|
+
const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
202
|
+
fs.writeFileSync(pptxPath, buf);
|
|
203
|
+
return { count: variants.length, warnings };
|
|
204
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Morph transition on the "(cont.)" slides (post-processing).
|
|
3
|
+
*
|
|
4
|
+
* When pagination splits a dense slide, the chrome (title, rules) keeps its
|
|
5
|
+
* geometry from one page to the next: that is exactly the ground the
|
|
6
|
+
* PowerPoint Morph transition (2019+/365) covers. We reopen the zip and, for
|
|
7
|
+
* each continuation slide, inject `<p159:morph>` wrapped in
|
|
8
|
+
* `mc:AlternateContent` with a `<p:fade/>` fallback — versions that ignore
|
|
9
|
+
* the p159 namespace play a fade, never an error.
|
|
10
|
+
*
|
|
11
|
+
* Shape pairing is made reliable by the PowerPoint "!!name" convention:
|
|
12
|
+
* every slide of one chain receives the same title name `!!title-N` in its
|
|
13
|
+
* `cNvPr` — Morph then pairs the titles by name despite the "(cont.)"
|
|
14
|
+
* suffix in the text, and the title glides instead of blinking. At the
|
|
15
|
+
* slightest structural disagreement, the chain is left intact — never a
|
|
16
|
+
* broken .pptx.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import JSZip from 'jszip';
|
|
21
|
+
|
|
22
|
+
const NS_MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006';
|
|
23
|
+
const NS_P159 = 'http://schemas.microsoft.com/office/powerpoint/2015/09/main';
|
|
24
|
+
const NS_P14 = 'http://schemas.microsoft.com/office/powerpoint/2010/main';
|
|
25
|
+
|
|
26
|
+
const MORPH_XML = `<mc:AlternateContent xmlns:mc="${NS_MC}"><mc:Choice xmlns:p159="${NS_P159}" Requires="p159"><p:transition xmlns:p14="${NS_P14}" spd="slow" p14:dur="700"><p159:morph option="byObject"/></p:transition></mc:Choice><mc:Fallback><p:transition spd="slow"><p:fade/></p:transition></mc:Fallback></mc:AlternateContent>`;
|
|
27
|
+
|
|
28
|
+
/** Renames the `cNvPr` of the first shape (the title — the renderer's first
|
|
29
|
+
* write on a content slide). Returns null if not found. */
|
|
30
|
+
function renameFirstShape(xml, newName) {
|
|
31
|
+
const spStart = xml.indexOf('<p:sp>');
|
|
32
|
+
if (spStart === -1) return null;
|
|
33
|
+
const zone = xml.slice(spStart, spStart + 400);
|
|
34
|
+
const m = zone.match(/<p:cNvPr id="(\d+)" name="([^"]*)"/);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
return (
|
|
37
|
+
xml.slice(0, spStart) +
|
|
38
|
+
zone.replace(m[0], `<p:cNvPr id="${m[1]}" name="${newName}"`) +
|
|
39
|
+
xml.slice(spStart + 400)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Injects the Morph transition into a .pptx already written to disk.
|
|
45
|
+
* Idempotent; any chain with an unexpected structure is ignored (and reported).
|
|
46
|
+
*
|
|
47
|
+
* @param {string} pptxPath path of the .pptx to modify in place
|
|
48
|
+
* @param {Array<number[]>} chains pagination chains: 1-based slide numbers,
|
|
49
|
+
* the first being the original slide and the following ones its
|
|
50
|
+
* "(cont.)" slides (the transition is placed on those).
|
|
51
|
+
* @returns {Promise<{count:number, warnings:string[]}>} transitions placed
|
|
52
|
+
*/
|
|
53
|
+
export async function embedMorph(pptxPath, chains) {
|
|
54
|
+
const warnings = [];
|
|
55
|
+
if (!chains?.length) return { count: 0, warnings };
|
|
56
|
+
const zip = await JSZip.loadAsync(fs.readFileSync(pptxPath));
|
|
57
|
+
let done = 0;
|
|
58
|
+
|
|
59
|
+
for (const [ci, chain] of chains.entries()) {
|
|
60
|
+
// atomic edit per chain: renaming the titles only makes sense if every
|
|
61
|
+
// slide in the chain conforms
|
|
62
|
+
const edits = [];
|
|
63
|
+
let ok = true;
|
|
64
|
+
for (const n of chain) {
|
|
65
|
+
const name = `ppt/slides/slide${n}.xml`;
|
|
66
|
+
const file = zip.file(name);
|
|
67
|
+
const xml = file && (await file.async('string'));
|
|
68
|
+
if (!xml) {
|
|
69
|
+
warnings.push(`slide ${n}: ${name} missing from the .pptx — Morph transition ignored`);
|
|
70
|
+
ok = false;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
let out = renameFirstShape(xml, `!!title-${ci + 1}`);
|
|
74
|
+
if (!out) {
|
|
75
|
+
warnings.push(`slide ${n}: title not found — Morph transition ignored`);
|
|
76
|
+
ok = false;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (n !== chain[0] && !out.includes('<p:transition') && !out.includes('p159:morph')) {
|
|
80
|
+
// CT_Slide schema position: the transition follows <p:clrMapOvr>
|
|
81
|
+
if (!out.includes('</p:clrMapOvr>')) {
|
|
82
|
+
warnings.push(
|
|
83
|
+
`slide ${n}: unexpected structure (no clrMapOvr) — Morph transition ignored`,
|
|
84
|
+
);
|
|
85
|
+
ok = false;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
out = out.replace('</p:clrMapOvr>', `</p:clrMapOvr>${MORPH_XML}`);
|
|
89
|
+
}
|
|
90
|
+
edits.push([name, out]);
|
|
91
|
+
}
|
|
92
|
+
if (!ok) continue;
|
|
93
|
+
for (const [name, out] of edits) zip.file(name, out);
|
|
94
|
+
done += chain.length - 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (done)
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
pptxPath,
|
|
100
|
+
await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
|
|
101
|
+
);
|
|
102
|
+
return { count: done, warnings };
|
|
103
|
+
}
|