@lutrin/core 1.0.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/README.md +15 -6
- package/package.json +4 -3
- package/src/cli.mjs +81 -2
- package/src/deck/assets.mjs +121 -13
- package/src/deck/browser.mjs +269 -0
- package/src/deck/layout.mjs +5 -2
- package/src/deck/marp.mjs +324 -0
- package/src/deck/mermaid-render.mjs +147 -0
- package/src/deck/parse.mjs +172 -3
- package/src/deck/validate.mjs +83 -22
- package/src/html/render.mjs +13 -3
- package/src/pptx/fonts.mjs +145 -7
- package/src/pptx/render.mjs +52 -14
- package/vendor/mermaid/LICENSE +21 -0
- package/vendor/mermaid/README.md +44 -0
- package/vendor/mermaid/mermaid.min.js +3587 -0
package/src/deck/parse.mjs
CHANGED
|
@@ -44,6 +44,14 @@
|
|
|
44
44
|
|
|
45
45
|
import MarkdownIt from 'markdown-it';
|
|
46
46
|
import container from 'markdown-it-container';
|
|
47
|
+
import {
|
|
48
|
+
classifyMarpDirective,
|
|
49
|
+
isMarpDeck,
|
|
50
|
+
marpBlocks,
|
|
51
|
+
marpMeta,
|
|
52
|
+
parseHeadingDivider,
|
|
53
|
+
parseMarpComment,
|
|
54
|
+
} from './marp.mjs';
|
|
47
55
|
|
|
48
56
|
export const CONTAINERS = ['info', 'success', 'warning', 'danger', 'metric'];
|
|
49
57
|
|
|
@@ -417,7 +425,26 @@ export function parseDeck(source) {
|
|
|
417
425
|
// as a ghost slide. CI covers windows-latest; the BOM does not cost a line,
|
|
418
426
|
// so source positions stay correct.
|
|
419
427
|
const { meta, body, lineOffset } = splitFrontmatter(source.replace(/^\uFEFF/, ''));
|
|
428
|
+
/** Marp dialect (`marp: true` in the frontmatter): slides split on `---`
|
|
429
|
+
* only, HTML comments are presenter notes, `![bg \u2026]` images become the
|
|
430
|
+
* slide background \u2014 see marp.mjs. The lutrin DSL is unchanged without
|
|
431
|
+
* the opt-in. */
|
|
432
|
+
const marp = isMarpDeck(meta);
|
|
433
|
+
const marpState = marp ? marpMeta(meta) : null;
|
|
420
434
|
const tokens = buildMd().parse(body, {});
|
|
435
|
+
if (marp) {
|
|
436
|
+
// headingDivider is a GLOBAL Marp directive: wherever it is written, the
|
|
437
|
+
// LAST definition wins and applies to the whole deck — the slides before
|
|
438
|
+
// the comment split too. Hence this pre-scan, before any slide is cut.
|
|
439
|
+
for (const t of tokens) {
|
|
440
|
+
if (t.type !== 'html_block') continue;
|
|
441
|
+
for (const d of parseMarpComment(t.content)?.directives ?? []) {
|
|
442
|
+
if (classifyMarpDirective(d.key) === 'divider') {
|
|
443
|
+
marpState.divider = parseHeadingDivider(d.value);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
421
448
|
// 1-based, in the source file (frontmatter included)
|
|
422
449
|
const lineOf = (t) => (t?.map ? t.map[0] + lineOffset + 1 : undefined);
|
|
423
450
|
|
|
@@ -469,6 +496,53 @@ export function parseDeck(source) {
|
|
|
469
496
|
pending = [];
|
|
470
497
|
};
|
|
471
498
|
|
|
499
|
+
/** Marp: has the slide's body opened yet? Images placed as backgrounds or
|
|
500
|
+
* split sides do not count — `` before the title is the
|
|
501
|
+
* canonical Marp order, and the title that follows still titles the
|
|
502
|
+
* slide. */
|
|
503
|
+
const marpSlideHasContent = (s) =>
|
|
504
|
+
s.sections.length > 1 ||
|
|
505
|
+
s.sections[0].heading !== null ||
|
|
506
|
+
s.sections[0].blocks.some(
|
|
507
|
+
(b) =>
|
|
508
|
+
!(
|
|
509
|
+
b.type === 'image' &&
|
|
510
|
+
(b.role === 'background' || b.role === 'left' || b.role === 'right')
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
/** Routes a parsed Marp comment (directives + notes) into the deck. In the
|
|
515
|
+
* Marp dialect a comment belongs to the slide it sits in — reading one
|
|
516
|
+
* therefore OPENS the slide, like any content would: an empty Marp slide
|
|
517
|
+
* carrying only a note exists and keeps its note. Directives with a lutrin
|
|
518
|
+
* equivalent go through the normal machinery; the others land in
|
|
519
|
+
* `marpState.ignored` so that validation reports them instead of losing
|
|
520
|
+
* them in silence. */
|
|
521
|
+
function routeMarpComment(read, line) {
|
|
522
|
+
if (!read || (!read.directives.length && !read.notes.length)) return;
|
|
523
|
+
ensureSlide();
|
|
524
|
+
for (const note of read.notes) applyDirective(slide, { key: 'notes', value: note, line });
|
|
525
|
+
for (const d of read.directives) {
|
|
526
|
+
switch (classifyMarpDirective(d.key)) {
|
|
527
|
+
case 'lutrin':
|
|
528
|
+
applyDirective(slide, { key: d.key, value: d.value, line });
|
|
529
|
+
break;
|
|
530
|
+
case 'footer':
|
|
531
|
+
// lutrin's footer is deck-wide: the last definition wins, the way a
|
|
532
|
+
// Marp footer redefined mid-deck applies from there on
|
|
533
|
+
meta.footer = d.value;
|
|
534
|
+
break;
|
|
535
|
+
case 'divider':
|
|
536
|
+
// global directive: already applied deck-wide by the pre-scan
|
|
537
|
+
break;
|
|
538
|
+
case 'ignored':
|
|
539
|
+
marpState.ignored.push({ key: d.key, value: d.value, line });
|
|
540
|
+
break;
|
|
541
|
+
default: // silent: the engine already decides this on its own
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
472
546
|
let i = 0;
|
|
473
547
|
const n = tokens.length;
|
|
474
548
|
|
|
@@ -487,7 +561,14 @@ export function parseDeck(source) {
|
|
|
487
561
|
* Every block returned carries `line` (position in the source file). */
|
|
488
562
|
function readBlock() {
|
|
489
563
|
const line = lineOf(tokens[i]);
|
|
490
|
-
|
|
564
|
+
let b = readBlockAt();
|
|
565
|
+
if (marp && b) {
|
|
566
|
+
// inline comments carry directives and notes too (Marpit reads them):
|
|
567
|
+
// collected during the strip, routed with the block's own line
|
|
568
|
+
const inline = [];
|
|
569
|
+
b = marpBlocks(b, (body) => inline.push(body));
|
|
570
|
+
for (const body of inline) routeMarpComment(parseMarpComment(`<!--${body}-->`), line);
|
|
571
|
+
}
|
|
491
572
|
if (b && line != null) {
|
|
492
573
|
for (const x of Array.isArray(b) ? b : [b]) x.line ??= line;
|
|
493
574
|
}
|
|
@@ -518,10 +599,17 @@ export function parseDeck(source) {
|
|
|
518
599
|
// nested block must not restart at "1." after the block. `startAt` is
|
|
519
600
|
// the same convention as the split performed by pagination.
|
|
520
601
|
let rank = 1;
|
|
602
|
+
/** Marp: `*` bullets and `1)` items are FRAGMENTED — they appear one by
|
|
603
|
+
* one. The flag is read per chunk: a list cut in two by a nested block
|
|
604
|
+
* keeps each half's own markers. */
|
|
605
|
+
let fragmented = false;
|
|
521
606
|
/** Closes the current list chunk to let a block through. */
|
|
522
607
|
const flushBullets = () => {
|
|
608
|
+
const frag = fragmented;
|
|
609
|
+
fragmented = false;
|
|
523
610
|
if (!items.length) return;
|
|
524
611
|
const b = { type: 'bullets', ordered, items };
|
|
612
|
+
if (marp && frag) b.fragmented = true;
|
|
525
613
|
if (ordered && rank > 1) b.startAt = rank;
|
|
526
614
|
if (itemsLine != null) b.line = itemsLine;
|
|
527
615
|
rank += items.filter((it) => !it.level).length;
|
|
@@ -552,6 +640,11 @@ export function parseDeck(source) {
|
|
|
552
640
|
i++;
|
|
553
641
|
continue;
|
|
554
642
|
}
|
|
643
|
+
if (u.type === 'list_item_open') {
|
|
644
|
+
if (marp && (u.markup === '*' || u.markup === ')')) fragmented = true;
|
|
645
|
+
i++;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
555
648
|
// A heading indented under a bullet does NOT open a section: its text
|
|
556
649
|
// stays a bullet. It is therefore counted as an item paragraph, or
|
|
557
650
|
// else the `para > 0` below would make it vanish without a trace.
|
|
@@ -663,6 +756,10 @@ export function parseDeck(source) {
|
|
|
663
756
|
}
|
|
664
757
|
case 'html_block': {
|
|
665
758
|
i++;
|
|
759
|
+
if (marp) {
|
|
760
|
+
routeMarpComment(parseMarpComment(t.content), lineOf(t));
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
666
763
|
const c = parseComment(t.content);
|
|
667
764
|
if (c) {
|
|
668
765
|
const directive = { ...c, line: lineOf(t) };
|
|
@@ -739,7 +836,48 @@ export function parseDeck(source) {
|
|
|
739
836
|
// a paragraph mixing text and images returns several blocks: all of them
|
|
740
837
|
// are placed, in source order
|
|
741
838
|
for (const block of Array.isArray(read) ? read : [read]) {
|
|
742
|
-
if (block.type === 'heading'
|
|
839
|
+
if (marp && block.type === 'heading') {
|
|
840
|
+
// Marp never splits on a heading — `---` does, and headingDivider
|
|
841
|
+
// restores the heading splits the deck asked for
|
|
842
|
+
if (marpState.divider?.has(block.depth)) pushSlide();
|
|
843
|
+
// Structural depths are RELATIVE to the slide's title: `#`/`##` are
|
|
844
|
+
// always structural (title or section), and below the title it is the
|
|
845
|
+
// FIRST subheading level actually used that opens the sections — a
|
|
846
|
+
// `##` slide subdivided with `###`, or with `####` inside the
|
|
847
|
+
// `<div class="columns">` idiom of Marp decks (the divs themselves
|
|
848
|
+
// are ignored HTML). Deeper headings stay subheadings in the flow.
|
|
849
|
+
const tDepth = slide?.marpTitleDepth;
|
|
850
|
+
const structural =
|
|
851
|
+
block.depth <= 2 ||
|
|
852
|
+
(tDepth != null &&
|
|
853
|
+
block.depth > tDepth &&
|
|
854
|
+
block.depth === (slide.marpSectionDepth ?? block.depth));
|
|
855
|
+
if (structural) {
|
|
856
|
+
ensureSlide();
|
|
857
|
+
slide.line ??= block.line;
|
|
858
|
+
// the first heading of the slide titles it — unless the body
|
|
859
|
+
// already opened (a background image does not open it: `![bg]`
|
|
860
|
+
// before the title is the canonical Marp order); a heading met
|
|
861
|
+
// mid-flow opens a section, exactly like a `##` of the lutrin DSL
|
|
862
|
+
if (!slide.title && !marpSlideHasContent(slide) && block.depth <= 2) {
|
|
863
|
+
slide.titleRuns = block.runs;
|
|
864
|
+
slide.title = runsToText(block.runs).trim();
|
|
865
|
+
slide.marpTitleDepth = block.depth;
|
|
866
|
+
} else {
|
|
867
|
+
if (slide.marpTitleDepth != null && block.depth > slide.marpTitleDepth) {
|
|
868
|
+
slide.marpSectionDepth ??= block.depth;
|
|
869
|
+
}
|
|
870
|
+
if (curSection().heading !== null || curSection().blocks.length) {
|
|
871
|
+
slide.sections.push({ heading: block.runs, blocks: [] });
|
|
872
|
+
} else {
|
|
873
|
+
curSection().heading = block.runs;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
// deep heading: flows as content below
|
|
879
|
+
}
|
|
880
|
+
if (!marp && block.type === 'heading' && block.depth === 1) {
|
|
743
881
|
pushSlide();
|
|
744
882
|
ensureSlide();
|
|
745
883
|
slide.titleRuns = block.runs;
|
|
@@ -750,6 +888,12 @@ export function parseDeck(source) {
|
|
|
750
888
|
|
|
751
889
|
ensureSlide();
|
|
752
890
|
slide.line ??= block.line;
|
|
891
|
+
if (marp && block.fragmented) {
|
|
892
|
+
// a fragmented list reveals point by point: that is lutrin's animate,
|
|
893
|
+
// unless the slide opted out explicitly (<!-- animate: none -->)
|
|
894
|
+
delete block.fragmented;
|
|
895
|
+
if (slide.animate === null) slide.animate = true;
|
|
896
|
+
}
|
|
753
897
|
if (block.type === 'heading' && block.depth === 2) {
|
|
754
898
|
// new section (potential slot)
|
|
755
899
|
if (curSection().heading !== null || curSection().blocks.length) {
|
|
@@ -766,11 +910,36 @@ export function parseDeck(source) {
|
|
|
766
910
|
pushSlide();
|
|
767
911
|
dropPending();
|
|
768
912
|
|
|
913
|
+
if (marp) {
|
|
914
|
+
// several `![bg]` on one slide (Marp composes them side by side): lutrin
|
|
915
|
+
// has ONE background — the first wins, the others return to the flow
|
|
916
|
+
for (const s of slides) {
|
|
917
|
+
// parse-time bookkeeping, not part of the IR
|
|
918
|
+
delete s.marpTitleDepth;
|
|
919
|
+
delete s.marpSectionDepth;
|
|
920
|
+
let bg = false;
|
|
921
|
+
for (const sec of s.sections) {
|
|
922
|
+
for (const b of sec.blocks) {
|
|
923
|
+
if (b.type === 'image' && (b.role === 'background' || b.role === 'cover')) {
|
|
924
|
+
if (bg) b.role = 'auto';
|
|
925
|
+
bg = true;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
769
932
|
return {
|
|
770
933
|
meta,
|
|
771
|
-
|
|
934
|
+
// Marp keeps a slide that only carries a note (its comment is content);
|
|
935
|
+
// a slide with nothing at all is dropped in both dialects
|
|
936
|
+
slides: slides.filter(
|
|
937
|
+
(s) =>
|
|
938
|
+
s.title || s.sections.some((x) => x.blocks.length || x.heading) || (marp && s.notes.length),
|
|
939
|
+
),
|
|
772
940
|
// key absent when there is nothing to report: the IR of a healthy deck
|
|
773
941
|
// does not change shape for an anomaly it does not have
|
|
774
942
|
...(orphanDirectives.length ? { orphanDirectives } : {}),
|
|
943
|
+
...(marp && marpState.ignored.length ? { marpIgnored: marpState.ignored } : {}),
|
|
775
944
|
};
|
|
776
945
|
}
|
package/src/deck/validate.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { chartDataDiagnostics } from './chart.mjs';
|
|
|
37
37
|
import { LAYER_SHADES, PAGE, contentArea } from './tokens.mjs';
|
|
38
38
|
import { prepareDeckContext } from './context.mjs';
|
|
39
39
|
import { THEME_KEYS } from './theme.mjs';
|
|
40
|
+
import { isMarpDeck } from './marp.mjs';
|
|
40
41
|
import { closest } from './suggest.mjs';
|
|
41
42
|
|
|
42
43
|
/** Candidates for animation preset suggestions (names + French aliases). */
|
|
@@ -110,6 +111,24 @@ export function validateDeck(
|
|
|
110
111
|
// impossible parse into a diagnostic rather than a crash.
|
|
111
112
|
const lines = source.split(/\r?\n/);
|
|
112
113
|
if (typeof lines[0] === 'string') lines[0] = lines[0].replace(/^\uFEFF/, '');
|
|
114
|
+
|
|
115
|
+
// ------ IR -----------------------------------------------------------------
|
|
116
|
+
// Validation must never throw: a deck that cannot be parsed becomes a
|
|
117
|
+
// diagnostic, not a crash of the editor or of the CLI. Parsed BEFORE the
|
|
118
|
+
// text scan: the scan needs the deck's dialect, and only the parser knows
|
|
119
|
+
// it \u2014 frontmatter detection and quote stripping live there, a second
|
|
120
|
+
// ad hoc reading of `marp:` would inevitably diverge from it.
|
|
121
|
+
if (!deck) {
|
|
122
|
+
try {
|
|
123
|
+
deck = parseDeck(source);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
push('error', 'PARSE_ERROR', `Could not parse the document: ${e?.message ?? e}`, 1);
|
|
126
|
+
return diags;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Marp dialect: `:::` is not part of its syntax \u2014 a line starting with
|
|
130
|
+
// `:::` there is prose, not a mistyped directive. Whole scan off.
|
|
131
|
+
const marpDoc = isMarpDeck(deck.meta);
|
|
113
132
|
let inFence = null;
|
|
114
133
|
let inFrontmatter = lines[0]?.trim() === '---' ? 'open' : null;
|
|
115
134
|
lines.forEach((raw, k) => {
|
|
@@ -126,6 +145,7 @@ export function validateDeck(
|
|
|
126
145
|
return;
|
|
127
146
|
}
|
|
128
147
|
if (inFence) return;
|
|
148
|
+
if (marpDoc) return;
|
|
129
149
|
const dir = line.match(/^:{3,}\s*([A-Za-z][\w-]*)/);
|
|
130
150
|
// CASE-SENSITIVE comparison, like markdown-it-container when opening:
|
|
131
151
|
// `:::Info` opens no callout and renders as a literal paragraph.
|
|
@@ -153,17 +173,14 @@ export function validateDeck(
|
|
|
153
173
|
}
|
|
154
174
|
});
|
|
155
175
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
deck = parseDeck(source);
|
|
162
|
-
} catch (e) {
|
|
163
|
-
push('error', 'PARSE_ERROR', `Could not parse the document: ${e?.message ?? e}`, 1);
|
|
164
|
-
return diags;
|
|
176
|
+
/** Line (1-based) of a frontmatter key, to position a diagnostic. */
|
|
177
|
+
const metaLine = (key) => {
|
|
178
|
+
if (lines[0]?.trim() !== '---') return 1;
|
|
179
|
+
for (let k = 1; k < lines.length && lines[k].trim() !== '---'; k++) {
|
|
180
|
+
if (new RegExp(`^${key}\\s*:`).test(lines[k])) return k + 1;
|
|
165
181
|
}
|
|
166
|
-
|
|
182
|
+
return 1;
|
|
183
|
+
};
|
|
167
184
|
|
|
168
185
|
// Directives the parser could not attach to any slide (end of file, or `---`
|
|
169
186
|
// before any content). They have no effect: saying so here is the only trace
|
|
@@ -178,25 +195,46 @@ export function validateDeck(
|
|
|
178
195
|
);
|
|
179
196
|
}
|
|
180
197
|
|
|
181
|
-
|
|
198
|
+
// Marp directives with no lutrin equivalent: the parser collected them
|
|
199
|
+
// (deck.marpIgnored), each one is reported here — an author must never
|
|
200
|
+
// discover a lost directive by staring at the rendering. Info severity:
|
|
201
|
+
// the deck compiles, the engine simply decides these things itself.
|
|
202
|
+
// BEFORE the early return of EMPTY_DECK, for the same reason as the
|
|
203
|
+
// orphans: an empty deck loses its directives too.
|
|
204
|
+
for (const d of deck.marpIgnored ?? []) {
|
|
205
|
+
const at = d.line ?? metaLine(d.key);
|
|
206
|
+
if (d.key === 'theme') {
|
|
207
|
+
push(
|
|
208
|
+
'info',
|
|
209
|
+
'MARP_DIRECTIVE_IGNORED',
|
|
210
|
+
`Marp theme "${d.value}" ignored: lutrin themes come from kits — \`kit: <name>\` (frontmatter) or --kit. The generic theme applies.`,
|
|
211
|
+
at,
|
|
212
|
+
'kit:',
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
push(
|
|
216
|
+
'info',
|
|
217
|
+
'MARP_DIRECTIVE_IGNORED',
|
|
218
|
+
`Marp directive "${d.key}${d.value ? `: ${d.value}` : ''}" has no lutrin equivalent — ignored (layout, colors and chrome are decided by the engine and the theme).`,
|
|
219
|
+
at,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// In the Marp dialect, `title:` is HTML metadata — it creates no cover
|
|
225
|
+
// slide, so it cannot vouch for the deck the way a lutrin title does.
|
|
226
|
+
if (!deck.slides.length && (!deck.meta.title || marpDoc)) {
|
|
182
227
|
push(
|
|
183
228
|
'warning',
|
|
184
229
|
'EMPTY_DECK',
|
|
185
|
-
|
|
230
|
+
marpDoc
|
|
231
|
+
? 'No slides: in a Marp deck, `title:` is metadata and creates no slide — add body content (slides split on `---`).'
|
|
232
|
+
: 'No slides: neither a frontmatter `title:`, nor a `# heading` in the body.',
|
|
186
233
|
1,
|
|
187
234
|
);
|
|
188
235
|
return diags;
|
|
189
236
|
}
|
|
190
237
|
|
|
191
|
-
/** Line (1-based) of a frontmatter key, to position a diagnostic. */
|
|
192
|
-
const metaLine = (key) => {
|
|
193
|
-
if (lines[0]?.trim() !== '---') return 1;
|
|
194
|
-
for (let k = 1; k < lines.length && lines[k].trim() !== '---'; k++) {
|
|
195
|
-
if (new RegExp(`^${key}\\s*:`).test(lines[k])) return k + 1;
|
|
196
|
-
}
|
|
197
|
-
return 1;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
238
|
// `animate:` in the frontmatter (whole deck) — the same check as the slide
|
|
201
239
|
// comment, positioned on the frontmatter line
|
|
202
240
|
if (deck.meta.animate != null && !isKnownAnimateValue(deck.meta.animate)) {
|
|
@@ -658,8 +696,30 @@ export function capabilities() {
|
|
|
658
696
|
codeFences: ['mermaid', 'math', 'latex', 'tex', 'chart'],
|
|
659
697
|
comments: ['notes', 'layout', 'animate'],
|
|
660
698
|
animatePresets: [...ANIM_PRESETS],
|
|
661
|
-
frontmatter: [
|
|
699
|
+
frontmatter: [
|
|
700
|
+
'title',
|
|
701
|
+
'subtitle',
|
|
702
|
+
'author',
|
|
703
|
+
'date',
|
|
704
|
+
'footer',
|
|
705
|
+
'animate',
|
|
706
|
+
'kit',
|
|
707
|
+
'assets',
|
|
708
|
+
'marp',
|
|
709
|
+
],
|
|
662
710
|
outputs: ['pptx', 'html'],
|
|
711
|
+
marp:
|
|
712
|
+
'`marp: true` (frontmatter) reads the deck as Marp Markdown (Marpit + Marp Core): slides split on ' +
|
|
713
|
+
'`---` only (headingDivider honoured — global, last definition wins), the first #/## of a slide is ' +
|
|
714
|
+
'its title, and the first subheading level used below it opens sections (## under a # title, ### ' +
|
|
715
|
+
'or #### under a ## title — the <div class="columns"> idiom maps to real columns, the divs being ' +
|
|
716
|
+
'ignored HTML). HTML comments — inline ones included — are presenter notes unless they carry ' +
|
|
717
|
+
'directives, `![bg …]` images become the slide background (`bg left`/`bg right` = split side), Marp ' +
|
|
718
|
+
'sizing/filter keywords in alts are consumed, fragmented lists (`*`, `1)`) animate their slide, ' +
|
|
719
|
+
'<!-- fit --> is stripped. footer: maps onto the deck footer. Directives with no equivalent (style, ' +
|
|
720
|
+
'header, background*, color, size other than 16:9, Marp theme:) are reported by ' +
|
|
721
|
+
'MARP_DIRECTIVE_IGNORED — themes come from kits (kit:), which works in a Marp deck too, as do ' +
|
|
722
|
+
'<!-- layout: … -->, <!-- notes: … -->, <!-- animate --> and the ::: callouts.',
|
|
663
723
|
remoteImages:
|
|
664
724
|
'`` images are downloaded then embedded in the deliverable (the presentation has no ' +
|
|
665
725
|
'network dependency). They land in the user cache ~/.cache/lutrin/remote/, shared between projects — ' +
|
|
@@ -732,6 +792,7 @@ export function capabilities() {
|
|
|
732
792
|
'USER_CONFIG_INVALID',
|
|
733
793
|
'LAYOUT_DEF_INVALID',
|
|
734
794
|
'LAYOUT_DEF_ADJUSTED',
|
|
795
|
+
'MARP_DIRECTIVE_IGNORED',
|
|
735
796
|
],
|
|
736
797
|
});
|
|
737
798
|
}
|
package/src/html/render.mjs
CHANGED
|
@@ -735,7 +735,7 @@ function htmlMermaid(block, r, ctx) {
|
|
|
735
735
|
const svg = ctx.mermaid.get(block);
|
|
736
736
|
if (svg) return `<div class="figure mermaid el" style="${at(r, true)}">${svg}</div>`;
|
|
737
737
|
// faithful fallback: source shown as a code block + a caption
|
|
738
|
-
return `${htmlCode({ lang: 'mermaid', source: block.source }, { ...r, h: r.h - 24 })}<div class="fallback-caption el" style="left:${Math.round(r.x)}px;top:${Math.round(r.y + r.h - 22)}px;width:${Math.round(r.w)}px;">Mermaid diagram —
|
|
738
|
+
return `${htmlCode({ lang: 'mermaid', source: block.source }, { ...r, h: r.h - 24 })}<div class="fallback-caption el" style="left:${Math.round(r.x)}px;top:${Math.round(r.y + r.h - 22)}px;width:${Math.round(r.w)}px;">Mermaid diagram — run \`lutrin setup-mermaid\` for graphical rendering</div>`;
|
|
739
739
|
}
|
|
740
740
|
|
|
741
741
|
function htmlIcon(block, r, ctx) {
|
|
@@ -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));
|