@lutrin/core 1.0.0 → 1.1.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.
@@ -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 — `![bg …](…)` 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
- const b = readBlockAt();
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' && block.depth === 1) {
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
- slides: slides.filter((s) => s.title || s.sections.some((x) => x.blocks.length || x.heading)),
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
  }
@@ -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
- // ------ IR -----------------------------------------------------------------
157
- // Validation must never throw: a deck that cannot be parsed becomes a
158
- // diagnostic, not a crash of the editor or of the CLI.
159
- if (!deck) {
160
- try {
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
- if (!deck.slides.length && !deck.meta.title) {
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
- 'No slides: neither a frontmatter `title:`, nor a `# heading` in the body.',
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: ['title', 'subtitle', 'author', 'date', 'footer', 'animate', 'kit', 'assets'],
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
  '`![](https://…)` 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
  }
@@ -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 — install @mermaid-js/mermaid-cli for graphical rendering</div>`;
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) {
@@ -151,7 +151,12 @@ function addPara(slide, block, r) {
151
151
  color: block.color ?? COLORS.neutralPrimary,
152
152
  align: 'left',
153
153
  valign: 'top',
154
- lineSpacingMultiple: LINE_HEIGHT,
154
+ // exact points, never a multiple: OOXML's spcPct multiplies the FONT'S
155
+ // own line metrics, so a kit font with tall ascenders rendered ~20%
156
+ // taller than blockHeight() measured and crowded whatever followed.
157
+ // spcPts pins the pitch to what the layout engine and the HTML (.para
158
+ // line-height 1.4) both assume, whatever font the kit ships.
159
+ lineSpacing: TYPE.body * LINE_HEIGHT,
155
160
  });
156
161
  }
157
162
 
@@ -168,8 +173,9 @@ function addHeading(slide, block, r) {
168
173
  bold: true,
169
174
  valign: 'top',
170
175
  ...(block.align ? { align: block.align } : {}),
171
- // multi-line message: same line height as the CSS .slot-heading (1.3)
172
- ...(block.size ? { lineSpacingMultiple: 1.3 } : {}),
176
+ // multi-line message: same line height as the CSS .slot-heading (1.3),
177
+ // in exact points (spcPts) see addPara on why never a multiple
178
+ ...(block.size ? { lineSpacing: block.size * 1.3 } : {}),
173
179
  });
174
180
  }
175
181
 
@@ -194,6 +200,13 @@ function addBullets(slide, block, r) {
194
200
  options: { fontFace: FONTS.body, color: block.color ?? COLORS.neutralPrimary },
195
201
  });
196
202
  }
203
+ if (it.level) {
204
+ // nested items: same size and pitch as the HTML (.bullets ul ul)
205
+ itemRuns.forEach((run) => {
206
+ run.options.fontSize = TYPE.bulletNested;
207
+ });
208
+ itemRuns[0].options.lineSpacing = TYPE.bulletNested * 1.3;
209
+ }
197
210
  itemRuns[0] = {
198
211
  ...itemRuns[0],
199
212
  options: {
@@ -222,8 +235,10 @@ function addBullets(slide, block, r) {
222
235
  fontFace: FONTS.body,
223
236
  color: block.color ?? COLORS.neutralPrimary,
224
237
  valign: 'top',
225
- lineSpacingMultiple: 1.25,
226
- paraSpaceAfter: 6,
238
+ // exact pitch and gap of the HTML (.bullets: line-height 1.3, li
239
+ // margin-bottom 6px) — spcPts + 4.5 pt (= 6 px); see addPara
240
+ lineSpacing: TYPE.bullet * 1.3,
241
+ paraSpaceAfter: 4.5,
227
242
  });
228
243
  }
229
244
 
@@ -259,7 +274,8 @@ function addCode(slide, block, r) {
259
274
  h: px(r.h - 2 * SPACE.xs),
260
275
  fontSize: TYPE.code,
261
276
  valign: 'top',
262
- lineSpacingMultiple: 1.25,
277
+ // exact pitch of the HTML (.code line-height 1.3); see addPara
278
+ lineSpacing: TYPE.code * 1.3,
263
279
  });
264
280
  }
265
281
 
@@ -308,7 +324,13 @@ function addAlert(slide, block, r) {
308
324
  const runs = [
309
325
  {
310
326
  text: sem.label,
311
- options: { bold: true, fontSize: TYPE.small, color: sem.text, breakLine: true },
327
+ options: {
328
+ bold: true,
329
+ fontSize: TYPE.small,
330
+ color: sem.text,
331
+ breakLine: true,
332
+ lineSpacing: TYPE.small * 1.3,
333
+ },
312
334
  },
313
335
  ];
314
336
  // outside ALERT_BLOCK_TYPES: ignored (height not reserved by blockHeight,
@@ -336,7 +358,9 @@ function addAlert(slide, block, r) {
336
358
  fontSize: TYPE.body,
337
359
  fontFace: FONTS.body,
338
360
  valign: 'top',
339
- lineSpacingMultiple: 1.3,
361
+ // exact pitch of the HTML (.alert line-height 1.3); the label paragraph
362
+ // carries its own smaller pitch in its run options — see addPara
363
+ lineSpacing: TYPE.body * 1.3,
340
364
  });
341
365
  }
342
366
 
@@ -522,7 +546,8 @@ function addQuote(slide, block, r) {
522
546
  color: COLORS.neutralPrimary,
523
547
  fontFace: FONTS.body,
524
548
  valign: 'middle',
525
- lineSpacingMultiple: LINE_HEIGHT,
549
+ // exact pitch of the HTML (.quote blockquote line-height 1.4); see addPara
550
+ lineSpacing: TYPE.quote * LINE_HEIGHT,
526
551
  });
527
552
  if (block.cite) {
528
553
  slide.addText(`— ${block.cite}`, {
@@ -598,7 +623,7 @@ function addMermaid(slide, block, r, ctx) {
598
623
  }
599
624
  // faithful fallback: source shown as a code block plus a caption
600
625
  addCode(slide, { type: 'code', lang: 'mermaid', source: block.source }, { ...r, h: r.h - 24 });
601
- slide.addText('Mermaid diagram — install @mermaid-js/mermaid-cli for graphical rendering', {
626
+ slide.addText('Mermaid diagram — run `lutrin setup-mermaid` for graphical rendering', {
602
627
  x: px(r.x),
603
628
  y: px(r.y + r.h - 22),
604
629
  w: px(r.w),
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014 - 2022 Knut Sveidqvist
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ # Vendored Mermaid
2
+
3
+ `mermaid.min.js` — the standalone UMD bundle of [Mermaid](https://mermaid.js.org),
4
+ copied verbatim from the npm package. MIT, see `LICENSE`.
5
+
6
+ | | |
7
+ |---|---|
8
+ | Version | 11.16.0 |
9
+ | Source | `mermaid@11.16.0/dist/mermaid.min.js` |
10
+ | SHA-256 | `74d7c46dabca328c2294733910a8aa1ed0c37451776e8d5295da38a2b758fb9b` |
11
+
12
+ ## Why a copy in the repository rather than a dependency
13
+
14
+ Mermaid needs a browser: it measures the text it lays out, so it cannot render
15
+ without a layout engine. The two obvious ways to get one both cost far more
16
+ than this file:
17
+
18
+ - `@mermaid-js/mermaid-cli` pulls Puppeteer, which downloads a Chrome — 405 MB
19
+ of `node_modules` plus ~540 MB of browser, measured, on every install. That is
20
+ what made mermaid-cli an optional peer dependency nobody installs, and
21
+ therefore what made diagrams silently degrade to a text fallback on a fresh
22
+ machine.
23
+ - Depending on `mermaid` itself installs 83 MB, of which 56 MB are source maps.
24
+ We load exactly one file out of it.
25
+
26
+ So we take the one file. `browser.mjs` finds a browser already on the machine
27
+ and `mermaid-render.mjs` loads this bundle into it; `@resvg/resvg-js`, already a
28
+ dependency, turns the SVG into the PNG the .pptx needs.
29
+
30
+ ## Upgrading
31
+
32
+ Version-bumping is a copy, and the two version markers must move together:
33
+
34
+ ```sh
35
+ npm pack mermaid@<version> # or npm i mermaid@<version> in a scratch dir
36
+ cp .../mermaid/dist/mermaid.min.js packages/core/vendor/mermaid/
37
+ shasum -a 256 packages/core/vendor/mermaid/mermaid.min.js
38
+ ```
39
+
40
+ Then update the table above, the `@mermaid-js/mermaid-cli` peer range in
41
+ `packages/core/package.json` (kept in step so both rendering paths speak the
42
+ same Mermaid dialect), and `THIRD-PARTY-NOTICES.md`. `test/mermaid.test.mjs`
43
+ asserts the bundle is present and that its recorded SHA-256 matches, so a
44
+ half-finished upgrade fails the suite rather than shipping.