@lutrin/core 1.1.0 → 1.1.1
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/package.json +1 -1
- package/src/deck/assets.mjs +5 -1
- package/src/html/render.mjs +12 -2
- package/src/pptx/fonts.mjs +145 -7
- package/src/pptx/render.mjs +17 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lutrin/core",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Markdown → PowerPoint / HTML presentation compiler, themable: a generic default theme, organization brand kits distributed as .deckkit archives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
package/src/deck/assets.mjs
CHANGED
|
@@ -642,11 +642,15 @@ export async function iconSvg(name, { color = 'primary' } = {}) {
|
|
|
642
642
|
return svg.replace(/currentColor/g, `#${hex}`);
|
|
643
643
|
}
|
|
644
644
|
|
|
645
|
+
/** Icon raster density (px) — shared with the callers that separate the SVG
|
|
646
|
+
* lookup from the rasterization to tell the two failures apart. */
|
|
647
|
+
export const ICON_RASTER_PX = 384;
|
|
648
|
+
|
|
645
649
|
/**
|
|
646
650
|
* Renders a Lucide icon as a recolored PNG.
|
|
647
651
|
* @returns {{png:Buffer,w:number,h:number}|null}
|
|
648
652
|
*/
|
|
649
|
-
export async function renderIcon(name, { color = 'primary', rasterPx =
|
|
653
|
+
export async function renderIcon(name, { color = 'primary', rasterPx = ICON_RASTER_PX } = {}) {
|
|
650
654
|
const svg = await iconSvg(name, { color });
|
|
651
655
|
return svg ? svgToPng(svg, rasterPx) : null;
|
|
652
656
|
}
|
package/src/html/render.mjs
CHANGED
|
@@ -910,7 +910,14 @@ body{margin:0;background:#${C.underground2};font-family:"${FONTS.body}",-apple-s
|
|
|
910
910
|
.el{position:absolute;margin:0}
|
|
911
911
|
a{color:#${C.primary};text-decoration:none}
|
|
912
912
|
a:hover{text-decoration:underline}
|
|
913
|
-
code
|
|
913
|
+
/* inline code — every surface property EXPLICIT, not only the ones the theme
|
|
914
|
+
cares about. The fragment mode injects this CSS into hosts (VS Code
|
|
915
|
+
webview, Obsidian) whose own default stylesheets give "code" a padded,
|
|
916
|
+
theme-colored chip (VS Code: --vscode-textPreformat-background); any
|
|
917
|
+
property left undeclared here is repainted by the host — dark chips under
|
|
918
|
+
dark editor themes, unreadable on a light slide. (No backticks in these
|
|
919
|
+
comments: we are inside a JS template literal.) */
|
|
920
|
+
code{font-family:"${FONTS.mono}",monospace;color:#${C.primaryDarker};background:transparent;padding:0;border-radius:0}
|
|
914
921
|
|
|
915
922
|
/* chrome of content slides */
|
|
916
923
|
.slide-title{position:absolute;left:${PAGE.margin}px;top:${SPACE.lg}px;width:${PAGE.width - 2 * PAGE.margin}px;height:${PAGE.titleHeight - SPACE.lg - 8}px;display:flex;align-items:center;font-size:${TYPE.slideTitle}pt;font-weight:700;line-height:1.15}
|
|
@@ -969,7 +976,10 @@ code{font-family:"${FONTS.mono}",monospace;color:#${C.primaryDarker}}
|
|
|
969
976
|
.tl-axis-v.tl-no-arrow{background-size:2px 100%}
|
|
970
977
|
.tl-arrow-v{position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:0;height:0;border-top:14px solid #${C.neutralStroke};border-left:7px solid transparent;border-right:7px solid transparent}
|
|
971
978
|
.tl-dot{display:flex;align-items:center;justify-content:center;background:#${C.primary};color:#${C.ground};border:2px solid #${C.ground};border-radius:50%;font-size:${TYPE.metricLabel}pt;font-weight:700;box-sizing:border-box}
|
|
972
|
-
|
|
979
|
+
/* same host-default hazard as "code" above: the webview paints blockquote
|
|
980
|
+
with --vscode-textBlockQuote-background and a left border — neutralized
|
|
981
|
+
explicitly */
|
|
982
|
+
.quote blockquote{position:absolute;left:96px;right:32px;top:0;bottom:64px;display:flex;align-items:center;margin:0;padding:0;background:transparent;border:0;font-size:${TYPE.quote}pt;font-style:italic;line-height:1.4}
|
|
973
983
|
.quote-mark{position:absolute;left:0;top:-10px;font-size:72pt;font-weight:700;color:#${C.primary};line-height:1}
|
|
974
984
|
.quote figcaption{position:absolute;right:32px;bottom:12px;font-size:${TYPE.body}pt;color:#${C.neutralSecondary}}
|
|
975
985
|
.img-contain{object-fit:contain}
|
package/src/pptx/fonts.mjs
CHANGED
|
@@ -52,22 +52,29 @@ const FSTYPE_OFFSET = 8;
|
|
|
52
52
|
const FSTYPE_RESTRICTED = 0x0002; // "Restricted License embedding": no
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* Table record of an sfnt font whose header (offset table) starts at `base`.
|
|
56
56
|
* The header: version on 4 bytes, numTables on 2, then three binary-search
|
|
57
57
|
* fields we ignore; the table records follow at +12, 16 bytes each (tag,
|
|
58
|
-
* checksum, offset, length).
|
|
59
|
-
*
|
|
58
|
+
* checksum, offset, length). Offsets are counted from the START OF THE FILE —
|
|
59
|
+
* that is what lets two fonts of a .ttc collection share a table.
|
|
60
|
+
* @returns {{offset:number,length:number}|null}
|
|
60
61
|
*/
|
|
61
|
-
function
|
|
62
|
+
function sfntTable(buf, base, tag) {
|
|
62
63
|
const numTables = buf.readUInt16BE(base + 4);
|
|
63
64
|
for (let k = 0; k < numTables; k++) {
|
|
64
65
|
const rec = base + 12 + k * 16;
|
|
65
|
-
if (buf.toString('latin1', rec, rec + 4)
|
|
66
|
-
|
|
66
|
+
if (buf.toString('latin1', rec, rec + 4) === tag)
|
|
67
|
+
return { offset: buf.readUInt32BE(rec + 8), length: buf.readUInt32BE(rec + 12) };
|
|
67
68
|
}
|
|
68
69
|
return null;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/** fsType of an sfnt font. @returns {number|null} null without an OS/2 table */
|
|
73
|
+
function sfntFsType(buf, base) {
|
|
74
|
+
const os2 = sfntTable(buf, base, 'OS/2');
|
|
75
|
+
return os2 ? buf.readUInt16BE(os2.offset + FSTYPE_OFFSET) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
/**
|
|
72
79
|
* Reads the embedding permission declared by a font file.
|
|
73
80
|
*
|
|
@@ -106,6 +113,99 @@ export function readFsType(file) {
|
|
|
106
113
|
}
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Embedded-font identity: the family name and style bits Windows matches by
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** fsSelection sits at +62 in the OS/2 table (after the vendor tag);
|
|
121
|
+
* macStyle at +44 in head. Bit layouts differ: fsSelection carries italic in
|
|
122
|
+
* bit 0 and bold in bit 5, macStyle bold in bit 0 and italic in bit 1. */
|
|
123
|
+
const FSSELECTION_OFFSET = 62;
|
|
124
|
+
const FSSELECTION_ITALIC = 0x01;
|
|
125
|
+
const FSSELECTION_BOLD = 0x20;
|
|
126
|
+
const MACSTYLE_OFFSET = 44;
|
|
127
|
+
const MACSTYLE_BOLD = 0x01;
|
|
128
|
+
const MACSTYLE_ITALIC = 0x02;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The identity PowerPoint on Windows will match the embedded font by.
|
|
132
|
+
*
|
|
133
|
+
* GDI knows nothing of the `typeface` we write around the fntdata part: it
|
|
134
|
+
* registers the font under ITS OWN family name — name table, nameID 1, the
|
|
135
|
+
* Windows platform (3) records, UTF-16BE — and pairs the four styles of a
|
|
136
|
+
* family through the bold/italic bits (OS/2 fsSelection, head macStyle as a
|
|
137
|
+
* fallback). NameID 16, the "typographic family" that lets macOS group
|
|
138
|
+
* single-style webfont families under one name, is precisely what GDI does
|
|
139
|
+
* NOT read — which is how a deck can be flawless on the Mac that produced it
|
|
140
|
+
* and greet every Windows recipient with "unable to install embedded fonts".
|
|
141
|
+
*
|
|
142
|
+
* @param {string} file path to a .ttf, .otf or .ttc (first font read)
|
|
143
|
+
* @returns {{family:string|null,bold:boolean,italic:boolean}|null}
|
|
144
|
+
* null if the file cannot be read at all; `family` null when the
|
|
145
|
+
* font carries no Windows-platform family record (nothing to check).
|
|
146
|
+
*/
|
|
147
|
+
export function readFontIdentity(file) {
|
|
148
|
+
let buf;
|
|
149
|
+
try {
|
|
150
|
+
buf = fs.readFileSync(file);
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const base = buf.toString('latin1', 0, 4) === 'ttcf' ? buf.readUInt32BE(12) : 0;
|
|
156
|
+
|
|
157
|
+
let family = null;
|
|
158
|
+
const name = sfntTable(buf, base, 'name');
|
|
159
|
+
if (name) {
|
|
160
|
+
const count = buf.readUInt16BE(name.offset + 2);
|
|
161
|
+
const storage = name.offset + buf.readUInt16BE(name.offset + 4);
|
|
162
|
+
let candidate = null; // any Windows record; an en-US one wins
|
|
163
|
+
for (let k = 0; k < count; k++) {
|
|
164
|
+
const rec = name.offset + 6 + k * 12;
|
|
165
|
+
const platform = buf.readUInt16BE(rec);
|
|
166
|
+
const language = buf.readUInt16BE(rec + 4);
|
|
167
|
+
const nameId = buf.readUInt16BE(rec + 6);
|
|
168
|
+
if (nameId !== 1 || (platform !== 3 && platform !== 0)) continue;
|
|
169
|
+
const at = storage + buf.readUInt16BE(rec + 10);
|
|
170
|
+
// copy before swap16(): it swaps IN PLACE and subarray() is a view
|
|
171
|
+
const value = Buffer.from(buf.subarray(at, at + buf.readUInt16BE(rec + 8)))
|
|
172
|
+
.swap16() // UTF-16BE in the file, utf16le for Buffer
|
|
173
|
+
.toString('utf16le');
|
|
174
|
+
if (platform === 3 && language === 0x409) {
|
|
175
|
+
candidate = value;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
candidate ??= value;
|
|
179
|
+
}
|
|
180
|
+
family = candidate;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let bold = false;
|
|
184
|
+
let italic = false;
|
|
185
|
+
const os2 = sfntTable(buf, base, 'OS/2');
|
|
186
|
+
if (os2 && os2.length >= FSSELECTION_OFFSET + 2) {
|
|
187
|
+
const fsSelection = buf.readUInt16BE(os2.offset + FSSELECTION_OFFSET);
|
|
188
|
+
bold = Boolean(fsSelection & FSSELECTION_BOLD);
|
|
189
|
+
italic = Boolean(fsSelection & FSSELECTION_ITALIC);
|
|
190
|
+
} else {
|
|
191
|
+
const head = sfntTable(buf, base, 'head');
|
|
192
|
+
if (head) {
|
|
193
|
+
const macStyle = buf.readUInt16BE(head.offset + MACSTYLE_OFFSET);
|
|
194
|
+
bold = Boolean(macStyle & MACSTYLE_BOLD);
|
|
195
|
+
italic = Boolean(macStyle & MACSTYLE_ITALIC);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { family, bold, italic };
|
|
199
|
+
} catch {
|
|
200
|
+
// offset out of bounds, truncated table: malformed font file
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Same string as far as Windows font matching cares: GDI compares family
|
|
206
|
+
* names case-insensitively, and "é" may travel composed or decomposed. */
|
|
207
|
+
const sameFamily = (a, b) => a.normalize('NFC').toLowerCase() === b.normalize('NFC').toLowerCase();
|
|
208
|
+
|
|
109
209
|
/**
|
|
110
210
|
* Embeds the brand font into a .pptx already written to disk.
|
|
111
211
|
* Idempotent; touches nothing if the TTFs are absent.
|
|
@@ -123,7 +223,7 @@ export async function embedFonts(pptxPath) {
|
|
|
123
223
|
|
|
124
224
|
// license filter: what is refused does not go into the deliverable, and the
|
|
125
225
|
// author learns it here rather than by receiving a cease-and-desist
|
|
126
|
-
const
|
|
226
|
+
const licensed = present.filter((v) => {
|
|
127
227
|
const fsType = readFsType(v.file);
|
|
128
228
|
if (fsType == null) {
|
|
129
229
|
warnings.push(
|
|
@@ -140,6 +240,44 @@ export async function embedFonts(pptxPath) {
|
|
|
140
240
|
return true;
|
|
141
241
|
});
|
|
142
242
|
|
|
243
|
+
// GDI coherence filter: Windows matches an embedded variant by the font's
|
|
244
|
+
// OWN identity (readFontIdentity), never by the typeface we declare around
|
|
245
|
+
// it. Webfonts commonly ship each weight as its own single-style family —
|
|
246
|
+
// the CSS @font-face re-groups them, macOS re-groups them through nameID 16,
|
|
247
|
+
// and GDI does neither: embedded as they are, every Windows recipient gets
|
|
248
|
+
// the "unable to install some embedded fonts / general failure" dialog at
|
|
249
|
+
// opening and reads the deck in the fallback font. Such a variant is NOT
|
|
250
|
+
// embedded — the deck keeps the documented installed-font fallback — and
|
|
251
|
+
// the author learns which table to rebuild, here rather than from a user's
|
|
252
|
+
// screenshot.
|
|
253
|
+
const STYLE_OF = {
|
|
254
|
+
regular: { bold: false, italic: false },
|
|
255
|
+
bold: { bold: true, italic: false },
|
|
256
|
+
italic: { bold: false, italic: true },
|
|
257
|
+
};
|
|
258
|
+
const variants = licensed.filter((v) => {
|
|
259
|
+
const id = readFontIdentity(v.file);
|
|
260
|
+
// unreadable or nameless: nothing to check against — the benefit of the
|
|
261
|
+
// doubt, same side as the unreadable fsType above
|
|
262
|
+
if (!id?.family) return true;
|
|
263
|
+
if (!sameFamily(id.family, FONTS.body)) {
|
|
264
|
+
warnings.push(
|
|
265
|
+
`Font "${path.basename(v.file)}" (${v.key}): its Windows family name is "${id.family}" while the theme declares "${FONTS.body}" — PowerPoint on Windows matches by that name and would fail to install the font at every recipient ("unable to install embedded fonts"). Not embedded; rebuild the font's name table (nameID 1) as "${FONTS.body}", or point fonts.files at a desktop cut of the family.`,
|
|
266
|
+
);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const want = STYLE_OF[v.key];
|
|
270
|
+
if (id.bold !== want.bold || id.italic !== want.italic) {
|
|
271
|
+
const said = (f) =>
|
|
272
|
+
[f.bold && 'bold', f.italic && 'italic'].filter(Boolean).join('+') || 'regular';
|
|
273
|
+
warnings.push(
|
|
274
|
+
`Font "${path.basename(v.file)}" (${v.key}): its style bits say "${said(id)}" where the ${v.key} slot requires "${said(want)}" — Windows pairs the styles of a family by those bits (OS/2 fsSelection, head macStyle) and would fail to install the font at every recipient. Not embedded; rebuild the font's style bits for the slot.`,
|
|
275
|
+
);
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
});
|
|
280
|
+
|
|
143
281
|
if (!variants.length) return { count: 0, warnings };
|
|
144
282
|
|
|
145
283
|
const zip = await JSZip.loadAsync(fs.readFileSync(pptxPath));
|
package/src/pptx/render.mjs
CHANGED
|
@@ -33,8 +33,9 @@ import {
|
|
|
33
33
|
import { ALERT_BLOCK_TYPES, runsToText } from '../deck/parse.mjs';
|
|
34
34
|
import {
|
|
35
35
|
fetchRemoteImage,
|
|
36
|
+
iconSvg,
|
|
37
|
+
ICON_RASTER_PX,
|
|
36
38
|
imageDims,
|
|
37
|
-
renderIcon,
|
|
38
39
|
renderMath,
|
|
39
40
|
renderMermaidCached,
|
|
40
41
|
rasterAvailable,
|
|
@@ -947,6 +948,9 @@ function renderCover(pptx, scene) {
|
|
|
947
948
|
bold: true,
|
|
948
949
|
color: COLORS.neutralPrimary,
|
|
949
950
|
fontFace: FONTS.body,
|
|
951
|
+
// explicit: a paragraph without `algn` inherits the master's titleStyle,
|
|
952
|
+
// which PptxGenJS hard-codes centered — the HTML output is left-aligned
|
|
953
|
+
align: 'left',
|
|
950
954
|
valign: 'top',
|
|
951
955
|
});
|
|
952
956
|
if (LOGOS.cover && fs.existsSync(LOGOS.cover)) {
|
|
@@ -1000,6 +1004,7 @@ function renderSection(pptx, scene) {
|
|
|
1000
1004
|
bold: true,
|
|
1001
1005
|
color: COLORS.ground,
|
|
1002
1006
|
fontFace: FONTS.body,
|
|
1007
|
+
align: 'left', // same inheritance trap as the cover title
|
|
1003
1008
|
valign: 'middle',
|
|
1004
1009
|
});
|
|
1005
1010
|
if (LOGOS.section && fs.existsSync(LOGOS.section)) {
|
|
@@ -1228,13 +1233,20 @@ async function renderDeckTo(scenes, meta, baseDir, outPath, tmp, opts = {}) {
|
|
|
1228
1233
|
const iconWarnings = new Array(iconBlocks.length);
|
|
1229
1234
|
await Promise.all(
|
|
1230
1235
|
iconBlocks.map(async (b, k) => {
|
|
1231
|
-
|
|
1232
|
-
|
|
1236
|
+
// two failures, two diagnostics: an icon whose SVG cannot be FOUND is a
|
|
1237
|
+
// deck problem (typo, package absent, offline), while an SVG found but
|
|
1238
|
+
// not RASTERIZED is the missing resvg binary — already reported, with
|
|
1239
|
+
// its remedy, by RASTER_UNAVAILABLE below. Conflating them sent Windows
|
|
1240
|
+
// users hunting for a network problem when their VSIX simply shipped
|
|
1241
|
+
// another platform's rasterizer.
|
|
1242
|
+
const svg = await iconSvg(b.name, { color: b.color });
|
|
1243
|
+
if (!svg) {
|
|
1233
1244
|
iconWarnings[k] =
|
|
1234
1245
|
`Icon "${iconLabel(b.name)}" not found — name unknown to Lucide, or the lucide-static package is absent and there is no network. The slide is rendered without it.`;
|
|
1235
1246
|
return;
|
|
1236
1247
|
}
|
|
1237
|
-
|
|
1248
|
+
const out = await svgToPng(svg, ICON_RASTER_PX);
|
|
1249
|
+
if (out) icons.set(b, writeTmpPng(tmp(), `icon-${k}-${iconSlug(b.name)}`, out.png));
|
|
1238
1250
|
}),
|
|
1239
1251
|
);
|
|
1240
1252
|
const assetWarnings = iconWarnings.filter(Boolean);
|
|
@@ -1335,6 +1347,7 @@ async function renderDeckTo(scenes, meta, baseDir, outPath, tmp, opts = {}) {
|
|
|
1335
1347
|
bold: true,
|
|
1336
1348
|
color: COLORS.neutralPrimary,
|
|
1337
1349
|
fontFace: FONTS.body,
|
|
1350
|
+
align: 'left', // same inheritance trap as the cover title
|
|
1338
1351
|
valign: 'middle',
|
|
1339
1352
|
},
|
|
1340
1353
|
);
|