@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.
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
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
+ }