@pptx-studio/cli 0.1.0 → 0.3.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.
@@ -11,7 +11,7 @@ import { bisectPackages, describeChange, flattenChanges, roundTripPackage, summa
11
11
  import { censusPackage, formatCensus } from "@pptx-studio/census";
12
12
  import { loadDocument } from "@pptx-studio/model";
13
13
  import { renderSlide } from "@pptx-studio/render-svg";
14
- import { LAST_RESORT_FAMILIES, kerningEnabled, substituteFor } from "@pptx-studio/text";
14
+ import { FACE_BOX_PX, LAST_RESORT_FAMILIES, TextError, cssFamily, fontStack, kerningEnabled, substituteFor } from "@pptx-studio/text";
15
15
  //#region src/bisect.ts
16
16
  const BISECT_DEFAULTS = {
17
17
  oracle: "validate",
@@ -272,6 +272,7 @@ const RENDER_ERROR_CODES = [
272
272
  "CLI_NO_FACE",
273
273
  "CLI_FONT_SIZE",
274
274
  "CLI_NO_SLIDE",
275
+ "CLI_WIDTH",
275
276
  "CLI_OUTPUT_PATH"
276
277
  ];
277
278
  var RenderError = class extends Error {
@@ -305,6 +306,12 @@ const TRUE_TYPE = 65536;
305
306
  const TTCF = 1953784678;
306
307
  /** `OS/2.fsSelection` bit 7: the typographic metrics are the ones to use. */
307
308
  const USE_TYPO_METRICS = 128;
309
+ /** FreeType on Linux, CoreText on macOS, DirectWrite elsewhere. ADR 0045, ADR 0053. */
310
+ function backendFor(platform) {
311
+ if (platform === "linux") return "freetype";
312
+ if (platform === "darwin") return "coretext";
313
+ return "directwrite";
314
+ }
308
315
  function readerOf(bytes) {
309
316
  return {
310
317
  bytes,
@@ -323,6 +330,9 @@ function i16(r, at) {
323
330
  function u32(r, at) {
324
331
  return r.view.getUint32(at);
325
332
  }
333
+ function tagAt(r, at) {
334
+ return String.fromCharCode(u8(r, at), u8(r, at + 1), u8(r, at + 2), u8(r, at + 3));
335
+ }
326
336
  function unreadable(what, subject) {
327
337
  throw new RenderError("CLI_FONT_UNREADABLE", what, subject);
328
338
  }
@@ -334,7 +344,7 @@ function directoryAt(bytes, start, subject) {
334
344
  for (let i = 0; i < count; i++) {
335
345
  const at = start + 12 + i * 16;
336
346
  if (at + 16 > bytes.length) unreadable("table directory runs past the file", subject);
337
- const tag = String.fromCharCode(u8(r, at), u8(r, at + 1), u8(r, at + 2), u8(r, at + 3));
347
+ const tag = tagAt(r, at);
338
348
  const offset = u32(r, at + 8);
339
349
  const length = u32(r, at + 12);
340
350
  if (offset < bytes.length) out.set(tag, bytes.subarray(offset, Math.min(offset + length, bytes.length)));
@@ -514,6 +524,10 @@ function valueSize(format) {
514
524
  }
515
525
  /** `XAdvance` is bit 2, and it is the only field a horizontal advance reads. */
516
526
  const X_ADVANCE = 4;
527
+ /** `LookupType` 2, pair adjustment: the only positioning that moves an advance. */
528
+ const PAIR_POS = 2;
529
+ /** `LookupType` 9, Extension Positioning, whose subtable names the real type. */
530
+ const EXTENSION_POS = 9;
517
531
  function pairPosLookup(r, at) {
518
532
  const format = u16(r, at);
519
533
  const valueFormat1 = u16(r, at + 4);
@@ -550,10 +564,23 @@ function pairPosLookup(r, at) {
550
564
  };
551
565
  }
552
566
  /**
567
+ * The pair adjustments behind an `ExtensionPosFormat1` subtable.
568
+ *
569
+ * The extension names the real lookup type and a 32-bit offset from its own
570
+ * start, and may not itself target another, so exactly one level is followed.
571
+ */
572
+ function extensionPairPos(r, at) {
573
+ const end = r.bytes.length;
574
+ if (at + 8 > end || u16(r, at) !== 1 || u16(r, at + 2) !== PAIR_POS) return void 0;
575
+ const target = at + u32(r, at + 4);
576
+ return target + 16 > end ? void 0 : pairPosLookup(r, target);
577
+ }
578
+ /**
553
579
  * The `kern` feature's pair adjustments, if the font has GPOS.
554
580
  *
555
581
  * Only the `kern` feature: a font's GPOS also carries mark attachment and
556
- * cursive positioning, and neither changes an advance.
582
+ * cursive positioning, and neither changes an advance. Its lookups are type 2,
583
+ * or the type 9 Extension a font compiler wraps them in.
557
584
  */
558
585
  function gposKerning(tables) {
559
586
  const table = tables.get("GPOS");
@@ -566,7 +593,7 @@ function gposKerning(tables) {
566
593
  const featureCount = u16(r, featureList);
567
594
  for (let i = 0; i < featureCount; i++) {
568
595
  const record = featureList + 2 + i * 6;
569
- if (String.fromCharCode(u8(r, record), u8(r, record + 1), u8(r, record + 2), u8(r, record + 3)) !== "kern") continue;
596
+ if (tagAt(r, record) !== "kern") continue;
570
597
  const feature = featureList + u16(r, record + 4);
571
598
  const lookups = u16(r, feature + 2);
572
599
  for (let j = 0; j < lookups; j++) wanted.add(u16(r, feature + 4 + j * 2));
@@ -577,10 +604,12 @@ function gposKerning(tables) {
577
604
  for (const index of wanted) {
578
605
  if (index >= lookupCount) continue;
579
606
  const lookup = lookupList + u16(r, lookupList + 2 + index * 2);
580
- if (u16(r, lookup) !== 2) continue;
607
+ const type = u16(r, lookup);
608
+ if (type !== PAIR_POS && type !== EXTENSION_POS) continue;
581
609
  const subtables = u16(r, lookup + 4);
582
610
  for (let i = 0; i < subtables; i++) {
583
- const pairs = pairPosLookup(r, lookup + u16(r, lookup + 6 + i * 2));
611
+ const at = lookup + u16(r, lookup + 6 + i * 2);
612
+ const pairs = type === PAIR_POS ? pairPosLookup(r, at) : extensionPairPos(r, at);
584
613
  if (pairs !== void 0) found.push(pairs);
585
614
  }
586
615
  }
@@ -620,39 +649,98 @@ function legacyKerning(tables) {
620
649
  return (left, right) => pairs.get(left << 16 | right) ?? 0;
621
650
  }
622
651
  /**
652
+ * The `BASE` horizontal axis' `ideo` coordinate under `DFLT`, in font units.
653
+ *
654
+ * One script and one tag: T13 built a font whose `DFLT`, `latn` and `hani`
655
+ * coordinates all differ and Chromium took `DFLT`, and a second whose `BASE`
656
+ * names every script but `DFLT` and Chromium read none of them. A table this
657
+ * cannot follow is answered with `undefined`, which is what HarfBuzz's
658
+ * sanitiser leaves the browser holding as well.
659
+ */
660
+ function ideographicOf(tables) {
661
+ const table = tables.get("BASE");
662
+ if (table === void 0 || table.length < 8) return void 0;
663
+ const r = readerOf(table);
664
+ /** An offset that is neither NULL nor short of the bytes it points at. */
665
+ const within = (offset, need) => offset > 0 && offset + need <= table.length ? offset : void 0;
666
+ const axis = within(u16(r, 4), 4);
667
+ if (axis === void 0) return void 0;
668
+ const tagList = within(axis + u16(r, axis), 2);
669
+ const scriptList = within(axis + u16(r, axis + 2), 2);
670
+ if (tagList === void 0 || scriptList === void 0) return void 0;
671
+ const tagCount = u16(r, tagList);
672
+ if (tagList + 2 + tagCount * 4 > table.length) return void 0;
673
+ let tag = -1;
674
+ for (let i = 0; i < tagCount && tag < 0; i++) if (tagAt(r, tagList + 2 + i * 4) === "ideo") tag = i;
675
+ if (tag < 0) return void 0;
676
+ const scriptCount = u16(r, scriptList);
677
+ if (scriptList + 2 + scriptCount * 6 > table.length) return void 0;
678
+ let script;
679
+ for (let i = 0; i < scriptCount && script === void 0; i++) {
680
+ const record = scriptList + 2 + i * 6;
681
+ if (tagAt(r, record) === "DFLT") script = within(scriptList + u16(r, record + 4), 6);
682
+ }
683
+ if (script === void 0) return void 0;
684
+ const values = within(script + u16(r, script), 4);
685
+ if (values === void 0) return void 0;
686
+ if (tag >= u16(r, values + 2) || values + 4 + tag * 2 + 2 > table.length) return void 0;
687
+ const coord = within(values + u16(r, values + 4 + tag * 2), 4);
688
+ if (coord === void 0) return void 0;
689
+ const format = u16(r, coord);
690
+ return format >= 1 && format <= 3 ? -i16(r, coord + 2) : void 0;
691
+ }
692
+ /**
623
693
  * The vertical metrics a browser reports for the face.
624
694
  *
625
- * T13 built one font whose `hhea`, `usWin` and `sTypo` pairs are all different
626
- * and asked Chromium for `fontBoundingBoxAscent`: it answered `usWin`, and
627
- * answered `sTypo` from the same font with `fsSelection` bit 7 set. So `hhea`
628
- * is never the answer, and a reader that ignores bit 7 is wrong on every font
629
- * that sets it, which is most of the ones shipped since 2015.
695
+ * Bit 7 wins on DirectWrite and FreeType, which otherwise answer `usWin` and
696
+ * `hhea`; CoreText answers `hhea` either way, 248/248. ADR 0045, ADR 0053.
630
697
  */
631
- function metricsOf(tables, subject) {
698
+ function metricsOf(tables, subject, backend) {
632
699
  const unitsPerEm = u16(readerOf(required(tables, "head", subject)), 18);
633
700
  if (unitsPerEm <= 0) unreadable("head.unitsPerEm is zero", subject);
701
+ const ideographic = ideographicOf(tables);
702
+ const hhea = readerOf(required(tables, "hhea", subject));
703
+ const hheaPair = {
704
+ ascent: i16(hhea, 4),
705
+ descent: -i16(hhea, 6)
706
+ };
634
707
  const os2 = tables.get("OS/2");
635
- if (os2 === void 0 || os2.length < 78) {
636
- const hhea = readerOf(required(tables, "hhea", subject));
637
- return {
638
- unitsPerEm,
639
- ascent: i16(hhea, 4),
640
- descent: -i16(hhea, 6),
641
- source: "usWin"
642
- };
643
- }
644
- const r = readerOf(os2);
645
- if ((u16(r, 62) & USE_TYPO_METRICS) !== 0) return {
708
+ if (os2 === void 0 || os2.length < 78) return {
646
709
  unitsPerEm,
710
+ ...hheaPair,
711
+ source: "hhea",
712
+ backend,
713
+ ideographic,
714
+ candidates: {
715
+ hhea: hheaPair,
716
+ usWin: void 0,
717
+ sTypo: void 0,
718
+ useTypoMetrics: false
719
+ }
720
+ };
721
+ const r = readerOf(os2);
722
+ const usWin = {
723
+ ascent: u16(r, 74),
724
+ descent: u16(r, 76)
725
+ };
726
+ const sTypo = {
647
727
  ascent: i16(r, 68),
648
- descent: -i16(r, 70),
649
- source: "sTypo"
728
+ descent: -i16(r, 70)
650
729
  };
730
+ const useTypoMetrics = (u16(r, 62) & USE_TYPO_METRICS) !== 0;
731
+ const source = backend === "coretext" ? "hhea" : useTypoMetrics ? "sTypo" : backend === "freetype" ? "hhea" : "usWin";
651
732
  return {
652
733
  unitsPerEm,
653
- ascent: u16(r, 74),
654
- descent: u16(r, 76),
655
- source: "usWin"
734
+ ...source === "sTypo" ? sTypo : source === "hhea" ? hheaPair : usWin,
735
+ source,
736
+ backend,
737
+ ideographic,
738
+ candidates: {
739
+ hhea: hheaPair,
740
+ usWin,
741
+ sTypo,
742
+ useTypoMetrics
743
+ }
656
744
  };
657
745
  }
658
746
  function advancesOf(tables, subject) {
@@ -668,7 +756,7 @@ function advancesOf(tables, subject) {
668
756
  const BOLD_STYLE = /bold|black|heavy|semibold|extrabold|demibold/i;
669
757
  const ITALIC_STYLE = /italic|oblique/i;
670
758
  /** Read one font out of an already-located table directory. */
671
- function faceOf(tables, subject) {
759
+ function faceOf(tables, subject, backend) {
672
760
  const names = namesOf(tables, subject);
673
761
  const family = names.get(1);
674
762
  if (family === void 0 || family === "") unreadable("no family name", subject);
@@ -681,7 +769,7 @@ function faceOf(tables, subject) {
681
769
  family,
682
770
  subfamily,
683
771
  typographicFamily: names.get(16),
684
- metrics: metricsOf(tables, subject),
772
+ metrics: metricsOf(tables, subject, backend),
685
773
  bold: BOLD_STYLE.test(subfamily) || (macStyle & 1) !== 0,
686
774
  italic: ITALIC_STYLE.test(subfamily) || (macStyle & 2) !== 0,
687
775
  advanceOf(codePoint) {
@@ -696,8 +784,8 @@ function faceOf(tables, subject) {
696
784
  };
697
785
  }
698
786
  /** Every face in a file, which is one unless the file is a collection. */
699
- function facesIn(bytes, subject) {
700
- return fontsIn(bytes, subject).map((tables) => faceOf(tables, subject));
787
+ function facesIn(bytes, subject, backend) {
788
+ return fontsIn(bytes, subject).map((tables) => faceOf(tables, subject, backend));
701
789
  }
702
790
  //#endregion
703
791
  //#region src/render/faces.ts
@@ -717,6 +805,21 @@ const MAX_DEPTH = 4;
717
805
  function key(family) {
718
806
  return family.trim().replace(/\s+/g, " ").toLowerCase();
719
807
  }
808
+ /** Whether a name can be written into the markup that would draw it. */
809
+ function emittable(family) {
810
+ try {
811
+ cssFamily(family);
812
+ return true;
813
+ } catch (error) {
814
+ if (error instanceof TextError && error.code === "TEXT_FONT_FAMILY") return false;
815
+ throw error;
816
+ }
817
+ }
818
+ /** Code unit order, which is the same on every machine and in every locale. */
819
+ function compare(a, b) {
820
+ if (a === b) return 0;
821
+ return a < b ? -1 : 1;
822
+ }
720
823
  /** Where fonts live on this platform, whether or not the directories exist. */
721
824
  function systemFontDirectories(platform = process.platform) {
722
825
  const home = homedir();
@@ -745,7 +848,7 @@ function filesUnder(directory, depth, out) {
745
848
  } catch {
746
849
  return;
747
850
  }
748
- for (const entry of entries) {
851
+ for (const entry of [...entries].sort((a, b) => compare(a.name, b.name))) {
749
852
  const path = join(directory, entry.name);
750
853
  if (entry.isDirectory()) filesUnder(path, depth + 1, out);
751
854
  else if (FONT_FILE.test(entry.name)) out.push(path);
@@ -761,6 +864,32 @@ function slot(bold, italic) {
761
864
  return (bold ? 1 : 0) | (italic ? 2 : 0);
762
865
  }
763
866
  /**
867
+ * The names a typeface is a variation of, longest first.
868
+ *
869
+ * DrawingML has no weight axis, so `Calibri Light` is a typeface name rather
870
+ * than Calibri at a weight, and Calibri is the nearer face to draw it in.
871
+ */
872
+ function shorterNames(family) {
873
+ const words = family.trim().split(/\s+/);
874
+ const names = [];
875
+ for (let at = words.length - 1; at > 0; at--) names.push(words.slice(0, at).join(" "));
876
+ return names;
877
+ }
878
+ /**
879
+ * The families to try for one this machine lacks, in order.
880
+ *
881
+ * The measured chain first - `@pptx-studio/text`'s table and then what
882
+ * PowerPoint itself falls back to, ADR 0033 - and the shorter forms of the
883
+ * asked-for name only after it, where no measurement reaches.
884
+ */
885
+ function standInsFor(family) {
886
+ return [
887
+ substituteFor(family)?.use,
888
+ ...LAST_RESORT_FAMILIES,
889
+ ...shorterNames(family).flatMap((name) => [name, substituteFor(name)?.use])
890
+ ].filter((name) => name !== void 0);
891
+ }
892
+ /**
764
893
  * Read every font file under the given directories, once.
765
894
  *
766
895
  * A file that will not parse is skipped rather than fatal: a font directory on
@@ -777,23 +906,28 @@ function indexFonts(options = {}) {
777
906
  const directories = [...options.extra ?? [], ...options.system === false ? [] : systemFontDirectories(options.platform)];
778
907
  const files = [];
779
908
  for (const directory of directories) filesUnder(directory, 0, files);
909
+ const backend = backendFor(options.platform ?? process.platform);
780
910
  const indexed = [];
781
911
  const byFamily = /* @__PURE__ */ new Map();
782
912
  const claim = (family, entry) => {
783
- const slots = byFamily.get(key(family)) ?? [
784
- void 0,
785
- void 0,
786
- void 0,
787
- void 0
788
- ];
789
- const at = slot(entry.face.bold, entry.face.italic);
790
- slots[at] ??= entry;
791
- byFamily.set(key(family), slots);
913
+ const at = key(family);
914
+ if (at === "" || !emittable(family)) return;
915
+ const found = byFamily.get(at) ?? {
916
+ name: family,
917
+ slots: [
918
+ void 0,
919
+ void 0,
920
+ void 0,
921
+ void 0
922
+ ]
923
+ };
924
+ found.slots[slot(entry.face.bold, entry.face.italic)] ??= entry;
925
+ byFamily.set(at, found);
792
926
  };
793
927
  for (const file of files) {
794
928
  let faces;
795
929
  try {
796
- faces = facesIn(new Uint8Array(readFileSync(file)), file);
930
+ faces = facesIn(new Uint8Array(readFileSync(file)), file, backend);
797
931
  } catch {
798
932
  continue;
799
933
  }
@@ -808,9 +942,7 @@ function indexFonts(options = {}) {
808
942
  }
809
943
  }
810
944
  /** The nearest slot to the one asked for: exact, then drop italic, then bold. */
811
- const pick = (family, bold, italic) => {
812
- const slots = byFamily.get(key(family));
813
- if (slots === void 0) return void 0;
945
+ const pickIn = (found, bold, italic) => {
814
946
  const wanted = [
815
947
  slot(bold, italic),
816
948
  slot(bold, false),
@@ -822,29 +954,37 @@ function indexFonts(options = {}) {
822
954
  3
823
955
  ];
824
956
  for (const at of wanted) {
825
- const found = slots[at];
826
- if (found !== void 0) return found;
957
+ const entry = found.slots[at];
958
+ if (entry !== void 0) return entry;
827
959
  }
828
960
  };
961
+ const drawnIn = (name, bold, italic, asked) => {
962
+ const family = byFamily.get(key(name));
963
+ if (family === void 0) return void 0;
964
+ const found = pickIn(family, bold, italic);
965
+ if (found === void 0) return void 0;
966
+ return {
967
+ face: found.face,
968
+ asked,
969
+ drawn: name,
970
+ file: found.file,
971
+ substituted: key(name) !== key(asked)
972
+ };
973
+ };
829
974
  return {
830
975
  indexed,
831
976
  directories,
832
977
  resolve(family, bold, italic) {
833
- const direct = pick(family, bold, italic);
834
- if (direct !== void 0) return {
835
- face: direct.face,
836
- asked: family,
837
- drawn: family,
838
- file: direct.file,
839
- substituted: false
840
- };
841
- const chain = [substituteFor(family)?.use, ...LAST_RESORT_FAMILIES].filter((name) => name !== void 0);
842
- for (const name of chain) {
843
- const found = pick(name, bold, italic);
978
+ for (const name of [family, ...standInsFor(family)]) {
979
+ const found = drawnIn(name, bold, italic, family);
980
+ if (found !== void 0) return found;
981
+ }
982
+ for (const [, entry] of [...byFamily].sort((a, b) => compare(a[0], b[0]))) {
983
+ const found = pickIn(entry, bold, italic);
844
984
  if (found !== void 0) return {
845
985
  face: found.face,
846
986
  asked: family,
847
- drawn: name,
987
+ drawn: entry.name,
848
988
  file: found.file,
849
989
  substituted: true
850
990
  };
@@ -867,17 +1007,16 @@ function indexFonts(options = {}) {
867
1007
  /**
868
1008
  * The fixed-point step Chromium reports an advance in.
869
1009
  *
870
- * T13 measured 252 widths across seven fonts, six strings and six sizes. Summing
871
- * exact floats fits 88 of them; quantising each glyph advance toward zero and
872
- * each kern adjustment to nearest, both at 1/65536 px, fits all 216. The
873
- * difference is never more than 1.6e-5 px and matters to nothing on a slide -
874
- * it is here because a rule that reproduces the browser exactly can be tested
875
- * exactly, and one that is merely close cannot.
1010
+ * Quantising each glyph advance toward zero and each kern adjustment to nearest
1011
+ * fits all 540 of T13's widths, where summing exact floats fits 234 and is out
1012
+ * by up to 1.04e-4 px. ADR 0042.
876
1013
  */
877
1014
  const FIXED = 65536;
1015
+ /** CoreText's `Fixed`: the em ratio is held as 16.16 before the box rounds. ADR 0053. */
1016
+ const EM_FIXED = 65536;
878
1017
  /**
879
- * Every advance in this font is positive, so truncation and flooring are the
880
- * same rule; T13 had no probe that could separate them and does not claim one.
1018
+ * `hmtx.advanceWidth` is a `UFWORD`, so this is never negative and truncation
1019
+ * and flooring are one rule. ADR 0042.
881
1020
  */
882
1021
  function quantiseAdvance(px) {
883
1022
  return Math.trunc(px * FIXED) / FIXED;
@@ -886,6 +1025,15 @@ function quantiseKern(px) {
886
1025
  return Math.round(px * FIXED) / FIXED;
887
1026
  }
888
1027
  /**
1028
+ * A box metric as the browser probe reports it: read at `FACE_BOX_PX`, rounded
1029
+ * half up to the pixel, and handed back as a fraction of the em. CoreText holds
1030
+ * the em ratio as 16.16 fixed point first. T13 30/30, T14 248/248. ADR 0053.
1031
+ */
1032
+ function boxPixels(units, unitsPerEm, backend) {
1033
+ const px = backend === "coretext" ? Math.round(units / unitsPerEm * EM_FIXED) / EM_FIXED * FACE_BOX_PX : units * FACE_BOX_PX / unitsPerEm;
1034
+ return Math.floor(px + .5) / FACE_BOX_PX;
1035
+ }
1036
+ /**
889
1037
  * A measurer bound to one library.
890
1038
  *
891
1039
  * Both probes cache by family, because a slide asks for the same handful of
@@ -897,11 +1045,14 @@ function createFontMeasurer(library) {
897
1045
  /** Which face draws a code point the asked-for face has no glyph for. */
898
1046
  const fallbacks = /* @__PURE__ */ new Map();
899
1047
  const faceFor = (family, bold, italic) => {
900
- const cacheKey = `${family}${bold ? "b" : ""}${italic ? "i" : ""}`;
1048
+ const cacheKey = `${family}\u0000${bold ? "b" : ""}${italic ? "i" : ""}`;
901
1049
  const cached = resolved.get(cacheKey);
902
1050
  if (cached !== void 0) return cached;
903
1051
  const found = library.resolve(family, bold, italic);
904
- if (found === void 0) throw new RenderError("CLI_NO_FACE", `no face on this machine can stand in for ${JSON.stringify(family)}; ${String(library.indexed.length)} face(s) were indexed from ${library.directories.join(", ")}`, family);
1052
+ if (found === void 0) {
1053
+ const where = library.directories.length === 0 ? "no directory was searched" : `nothing was found in ${library.directories.join(", ")}`;
1054
+ throw new RenderError("CLI_NO_FACE", `no face on this machine can stand in for ${JSON.stringify(family)}: the font library is empty, ${where}`, family);
1055
+ }
905
1056
  resolved.set(cacheKey, found);
906
1057
  return found;
907
1058
  };
@@ -974,14 +1125,20 @@ function createFontMeasurer(library) {
974
1125
  const cached = boxes.get(family);
975
1126
  if (cached !== void 0) return cached;
976
1127
  const { metrics } = faceFor(family, false, false).face;
1128
+ const descent = boxPixels(metrics.descent, metrics.unitsPerEm, metrics.backend);
1129
+ const ideographic = metrics.ideographic === void 0 ? descent : metrics.ideographic / metrics.unitsPerEm;
977
1130
  const box = {
978
- ascent: metrics.ascent / metrics.unitsPerEm,
979
- descent: metrics.descent / metrics.unitsPerEm,
980
- ideographic: metrics.descent / metrics.unitsPerEm
1131
+ ascent: boxPixels(metrics.ascent, metrics.unitsPerEm, metrics.backend),
1132
+ descent,
1133
+ ideographic: ideographic > 0 && ideographic < 1 ? ideographic : 0
981
1134
  };
982
1135
  boxes.set(family, box);
983
1136
  return box;
984
1137
  } },
1138
+ cssFamilyFor(font) {
1139
+ const face = faceFor(font.family, font.bold === true, font.italic === true);
1140
+ return fontStack(face.asked, face.drawn);
1141
+ },
985
1142
  used() {
986
1143
  const seen = /* @__PURE__ */ new Map();
987
1144
  for (const face of resolved.values()) seen.set(face.asked, {
@@ -1010,11 +1167,10 @@ function createFontMeasurer(library) {
1010
1167
  */
1011
1168
  /** What a slide is drawn at when the caller says nothing. PowerPoint's own. */
1012
1169
  const DEFAULT_WIDTH = 1920;
1013
- const RENDER_DEFAULTS = {
1014
- width: DEFAULT_WIDTH,
1015
- systemFonts: true,
1016
- text: true
1017
- };
1170
+ /** Text is drawn unless the caller says otherwise. */
1171
+ function drawsText(options) {
1172
+ return options.text ?? true;
1173
+ }
1018
1174
  /** The media resolver: an rId means the rels of the part the fill was written in. */
1019
1175
  function mediaFrom(store) {
1020
1176
  return (embed, part) => {
@@ -1031,46 +1187,50 @@ function mediaFrom(store) {
1031
1187
  /**
1032
1188
  * Render a deck that is already in memory.
1033
1189
  *
1034
- * Separate from `runRender` so that the whole pipeline is exercised by the test
1035
- * suite without a file system, a process or a captured stdout.
1190
+ * The library entry point: no file system, no process, no stdout. `runRender` is
1191
+ * the verb wrapped around it, and the three fields it adds are its own.
1036
1192
  */
1037
- function renderDeck(bytes, options) {
1193
+ function renderDeck(bytes, options = {}) {
1194
+ const width = options.width ?? 1920;
1195
+ if (!Number.isInteger(width) || width < 1) throw new RenderError("CLI_WIDTH", `a width of ${String(width)} is not a positive whole number of pixels`, String(width));
1196
+ const slide = options.slide ?? null;
1038
1197
  const store = PartStore.open(bytes);
1039
1198
  const document = loadDocument(store);
1040
1199
  const size = document.slideSize;
1041
- const height = Math.round(options.width * size.cy / size.cx);
1042
- if (options.slide !== null) {
1200
+ const height = Math.round(width * size.cy / size.cx);
1201
+ if (slide !== null) {
1043
1202
  const count = document.slides.length;
1044
- if (!Number.isInteger(options.slide) || options.slide < 1 || options.slide > count) throw new RenderError("CLI_NO_SLIDE", `--slide ${String(options.slide)}: the deck has ${String(count)} slide(s)`, String(options.slide));
1203
+ if (!Number.isInteger(slide) || slide < 1 || slide > count) throw new RenderError("CLI_NO_SLIDE", `slide ${String(slide)}: the deck has ${String(count)} slide(s)`, String(slide));
1045
1204
  }
1046
1205
  let library = null;
1047
- const fonts = options.text ? createFontMeasurer(library = indexFonts({
1048
- extra: options.fontDirs,
1049
- system: options.systemFonts
1206
+ const fonts = drawsText(options) ? createFontMeasurer(library = indexFonts({
1207
+ extra: options.fontDirs ?? [],
1208
+ system: options.systemFonts !== false
1050
1209
  })) : null;
1051
1210
  const media = mediaFrom(store);
1052
1211
  return {
1053
- slides: (options.slide === null ? document.slides.map((sheet, at) => ({
1212
+ slides: (slide === null ? document.slides.map((sheet, at) => ({
1054
1213
  sheet,
1055
1214
  number: at + 1
1056
1215
  })) : [{
1057
- sheet: document.slides[options.slide - 1],
1058
- number: options.slide
1216
+ sheet: document.slides[slide - 1],
1217
+ number: slide
1059
1218
  }]).map(({ sheet, number }) => ({
1060
1219
  number,
1061
1220
  svg: renderSlide(sheet, size, {
1062
- width: options.width,
1221
+ width,
1063
1222
  height,
1064
1223
  idPrefix: `s${String(number)}`,
1065
1224
  media,
1066
1225
  text: fonts === null ? false : {
1067
1226
  defaultTextStyle: document.defaultTextStyle,
1068
1227
  measurer: fonts.measurer,
1069
- faceBox: fonts.faceBox
1228
+ faceBox: fonts.faceBox,
1229
+ cssFamilyFor: fonts.cssFamilyFor
1070
1230
  }
1071
1231
  })
1072
1232
  })),
1073
- width: options.width,
1233
+ width,
1074
1234
  height,
1075
1235
  fonts: fonts?.used() ?? [],
1076
1236
  missing: fonts?.missing() ?? [],
@@ -1117,7 +1277,7 @@ function report(result, written, options) {
1117
1277
  const lines = [];
1118
1278
  const slides = `${String(result.slides.length)} slide(s) at ${String(result.width)}x${String(result.height)}`;
1119
1279
  lines.push(written.length === 0 ? slides : `${slides} -> ${written.length === 1 ? written[0] : `${String(written.length)} files`}`);
1120
- if (options.text) {
1280
+ if (drawsText(options)) {
1121
1281
  const substituted = result.fonts.filter((font) => font.substituted);
1122
1282
  lines.push(`${String(result.fonts.length)} typeface(s) from ${String(result.facesIndexed)} indexed face(s)` + (substituted.length === 0 ? "" : `, ${String(substituted.length)} substituted`));
1123
1283
  for (const font of substituted) lines.push(` ${font.asked} -> ${font.drawn}`);
@@ -1278,15 +1438,18 @@ function usage() {
1278
1438
  "",
1279
1439
  "render options:",
1280
1440
  " --slide <n> one slide, 1-based; every slide by default",
1281
- " --width <px> the SVG width attribute; the height follows the aspect",
1441
+ " --width <px> the SVG width attribute; the height follows the aspect (default " + String(DEFAULT_WIDTH) + ")",
1282
1442
  " --out <path> a directory, or a file when rendering one slide",
1283
1443
  " --font-dir <d> look for fonts here first; repeatable",
1284
- " --no-system-fonts do not look in this platform own font directories",
1444
+ " --no-system-fonts do not look in this platform's own font directories",
1285
1445
  " --no-text draw geometry only, and ask no font questions",
1286
1446
  " --json what was drawn, and which face drew each typeface",
1287
1447
  " --quiet no summary after writing",
1288
1448
  "",
1289
1449
  " With no --out the markup goes to stdout, which is one slide worth doing.",
1450
+ " In Node, `import { renderDeck } from \"@pptx-studio/cli\"` takes the five",
1451
+ " drawing options above and defaults every one; --out, --json and --quiet are",
1452
+ " this command's own.",
1290
1453
  "",
1291
1454
  "inspect options:",
1292
1455
  " --json the census as JSON, for a script or for committing as a fixture",
@@ -1474,7 +1637,7 @@ function main(argv, streams = CONSOLE_STREAMS) {
1474
1637
  streams.err("--slide wants a positive integer, got " + String(values.slide) + "\n");
1475
1638
  return 2;
1476
1639
  }
1477
- const width = positiveInteger(values.width, RENDER_DEFAULTS.width);
1640
+ const width = positiveInteger(values.width, DEFAULT_WIDTH);
1478
1641
  if (width === null) {
1479
1642
  streams.err("--width wants a positive integer, got " + String(values.width) + "\n");
1480
1643
  return 2;
@@ -1565,6 +1728,6 @@ function positiveInteger(value, fallback) {
1565
1728
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
1566
1729
  }
1567
1730
  //#endregion
1568
- export { runRender as a, systemFontDirectories as c, RenderError as d, isRenderError as f, runInspect as h, renderDeck as i, facesIn as l, inspectFile as m, DEFAULT_WIDTH as n, createFontMeasurer as o, INSPECT_DEFAULTS as p, RENDER_DEFAULTS as r, indexFonts as s, main as t, RENDER_ERROR_CODES as u };
1731
+ export { createFontMeasurer as a, backendFor as c, RenderError as d, isRenderError as f, runInspect as h, runRender as i, facesIn as l, inspectFile as m, DEFAULT_WIDTH as n, indexFonts as o, INSPECT_DEFAULTS as p, renderDeck as r, systemFontDirectories as s, main as t, RENDER_ERROR_CODES as u };
1569
1732
 
1570
- //# sourceMappingURL=main-DLx2onii.js.map
1733
+ //# sourceMappingURL=main-3sNR_Xlp.js.map