@json-to-office/core-pptx 6.6.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5163,36 +5163,8 @@ var DEFAULT_STYLES = {
5163
5163
  body: { fontSize: 14 },
5164
5164
  caption: { fontSize: 10, italic: true, fontColor: "text2" }
5165
5165
  };
5166
- var DEFAULT_PPTX_THEME = {
5167
- name: "default",
5168
- displayName: "Office Default",
5169
- description: "The stock Office look: blue primary, orange and green accents, Arial throughout, centred titles",
5170
- whenToUse: "A deck that has to match a plain PowerPoint look; pick a designed theme for anything a client will see.",
5171
- colors: {
5172
- primary: "#4472C4",
5173
- secondary: "#ED7D31",
5174
- accent: "#70AD47",
5175
- background: "#FFFFFF",
5176
- text: "#333333",
5177
- text2: "#44546A",
5178
- background2: "#E7E6E6",
5179
- accent4: "#FFC000",
5180
- accent5: "#5B9BD5",
5181
- accent6: "#70AD47"
5182
- },
5183
- fonts: {
5184
- heading: "Arial",
5185
- body: "Arial"
5186
- },
5187
- defaults: {
5188
- fontSize: 18,
5189
- fontColor: "#333333"
5190
- },
5191
- styles: DEFAULT_STYLES,
5192
- componentDefaults: { table: DEFAULT_TABLE }
5193
- };
5166
+ var DEFAULT_PPTX_THEME = CONSULTING_PPTX_THEME;
5194
5167
  var PPTX_THEMES = {
5195
- default: DEFAULT_PPTX_THEME,
5196
5168
  consulting: CONSULTING_PPTX_THEME,
5197
5169
  vermilion: VERMILION_PPTX_THEME,
5198
5170
  devportal: DEVPORTAL_PPTX_THEME,
@@ -5332,7 +5304,7 @@ function resolveThemeContext(documentIn, options = {}) {
5332
5304
  inlineTheme = document.props.theme;
5333
5305
  }
5334
5306
  const authoredThemeName = typeof document.props.theme === "string" ? document.props.theme : void 0;
5335
- const baseThemeName = inlineTheme ? inlineTheme.name || "inline-theme" : authoredThemeName ?? defaultThemeName ?? "default";
5307
+ const baseThemeName = inlineTheme ? inlineTheme.name || "inline-theme" : authoredThemeName ?? defaultThemeName ?? "consulting";
5336
5308
  let theme = inlineTheme ?? (resolveNamedTheme ? resolveNamedTheme(baseThemeName, authoredThemeName !== void 0) : customThemes?.[baseThemeName] ?? getPptxTheme(baseThemeName));
5337
5309
  theme = resolvePptxDesignSystem(
5338
5310
  theme,
@@ -5913,10 +5885,22 @@ async function expandPptxBlocksWithPlugins(document, theme, plugins, render, pre
5913
5885
  preserve
5914
5886
  });
5915
5887
  const finished = finishPptxBlocks(composed.standard, evaluator, effects2);
5916
- return { ...finished, preserved: composed.preserved };
5888
+ return {
5889
+ ...finished,
5890
+ preserved: composed.preserved,
5891
+ pluginOutputs: evaluator.pluginOutputs
5892
+ };
5917
5893
  }
5918
5894
 
5919
5895
  // src/quality/facts.ts
5896
+ function textInsetsPt(props, componentName) {
5897
+ const margin = props.margin;
5898
+ if (typeof margin === "number" && Number.isFinite(margin))
5899
+ return [margin, margin, margin, margin];
5900
+ if (Array.isArray(margin) && margin.length === 4 && margin.every((side) => typeof side === "number" && Number.isFinite(side)))
5901
+ return margin;
5902
+ return componentName === "shape" ? [3.6, 7.2, 3.6, 7.2] : [0, 0, 0, 0];
5903
+ }
5920
5904
  function asRecord(value) {
5921
5905
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
5922
5906
  }
@@ -5972,6 +5956,37 @@ function horizontalAlign(props, ctx) {
5972
5956
  const styled = typeof props.style === "string" ? ctx.styles[props.style]?.align : void 0;
5973
5957
  return styled && HORIZONTAL_ALIGNMENTS.has(styled) ? styled : "left";
5974
5958
  }
5959
+ var BLEED_TOLERANCE_PT = 2;
5960
+ var NOT_BODY_STYLES = /* @__PURE__ */ new Set([
5961
+ "title",
5962
+ "subtitle",
5963
+ "display",
5964
+ "tracker",
5965
+ "footer",
5966
+ "source"
5967
+ ]);
5968
+ function imageBox(node, box, statedPropsAt) {
5969
+ if (node.componentName !== "image" || !isCompleteBox(box)) return box;
5970
+ const ratio = readablePptxRatio(node.props);
5971
+ if (ratio === void 0 || ratio <= 0) return box;
5972
+ const authored = statedPropsAt(node.path) ?? node.props;
5973
+ const statesWidth = authored.w !== void 0;
5974
+ const statesHeight = authored.h !== void 0;
5975
+ if (statesWidth && !statesHeight)
5976
+ return { ...box, heightPt: box.widthPt / ratio };
5977
+ if (statesHeight && !statesWidth)
5978
+ return { ...box, widthPt: box.heightPt * ratio };
5979
+ return box;
5980
+ }
5981
+ function slotOrNode(nodePath, authoredPath) {
5982
+ for (const prop of ["text", "runs"]) {
5983
+ const compiled = `${nodePath}/props/${prop}`;
5984
+ const authored = authoredPath(compiled);
5985
+ if (authored !== compiled && authored.includes("/props/slots/"))
5986
+ return authored;
5987
+ }
5988
+ return nodePath;
5989
+ }
5975
5990
  function bulletItems(props) {
5976
5991
  const runs = Array.isArray(props.runs) ? props.runs : void 0;
5977
5992
  const whole = props.bullet !== void 0 && props.bullet !== false;
@@ -6130,7 +6145,7 @@ function isOpaqueComponent(componentName, props) {
6130
6145
  const type = typeof props.type === "string" ? props.type : "rect";
6131
6146
  return RECTANGULAR_SHAPES.has(type) && isOpaqueFill(props.fill);
6132
6147
  }
6133
- function collectSlideNodes(component, path4, text, surfaces, boxes, contentNodes, counter) {
6148
+ function collectSlideNodes(component, path4, text, richText, surfaces, boxes, contentNodes, counter) {
6134
6149
  const rec = asRecord(component);
6135
6150
  if (!rec || rec.enabled === false) return;
6136
6151
  const props = asRecord(rec.props) ?? {};
@@ -6143,9 +6158,28 @@ function collectSlideNodes(component, path4, text, surfaces, boxes, contentNodes
6143
6158
  const content = typeof props.text === "string" ? props.text : void 0;
6144
6159
  if (content !== void 0 && content.trim() !== "" && props.runs === void 0) {
6145
6160
  if (rec.name === "text" || rec.name === "shape") {
6146
- text.push({ props, path: path4, text: content, order });
6161
+ text.push({
6162
+ props,
6163
+ path: path4,
6164
+ componentName: rec.name,
6165
+ text: content,
6166
+ order
6167
+ });
6147
6168
  }
6148
6169
  }
6170
+ if (Array.isArray(props.runs) && (rec.name === "text" || rec.name === "shape")) {
6171
+ const joined = props.runs.map(asRecord).map(
6172
+ (run) => run && typeof run.text === "string" ? `${run.text}${run.breakLine === true ? "\n" : ""}` : ""
6173
+ ).join("");
6174
+ if (joined.trim() !== "")
6175
+ richText.push({
6176
+ props,
6177
+ path: path4,
6178
+ componentName: String(rec.name),
6179
+ text: joined,
6180
+ order
6181
+ });
6182
+ }
6149
6183
  const componentName = typeof rec.name === "string" ? rec.name : "";
6150
6184
  if (componentName === "chart" || componentName === "highcharts" || componentName === "table") {
6151
6185
  contentNodes.push({ props, path: path4, componentName });
@@ -6165,6 +6199,7 @@ function collectSlideNodes(component, path4, text, surfaces, boxes, contentNodes
6165
6199
  child,
6166
6200
  `${path4}/children/${index}`,
6167
6201
  text,
6202
+ richText,
6168
6203
  surfaces,
6169
6204
  boxes,
6170
6205
  contentNodes,
@@ -6318,6 +6353,16 @@ function tableFact(node, slidePath, authoredProps) {
6318
6353
  }
6319
6354
  return { ...base, columns };
6320
6355
  }
6356
+ function authoredNameAtPointer(root, pointer) {
6357
+ let node = root;
6358
+ for (const token of pointer.split("/").slice(1)) {
6359
+ const key = token.replace(/~1/g, "/").replace(/~0/g, "~");
6360
+ node = Array.isArray(node) ? node[Number(key)] : asRecord(node)?.[key];
6361
+ if (node === void 0) return void 0;
6362
+ }
6363
+ const name = asRecord(node)?.name;
6364
+ return typeof name === "string" ? name : void 0;
6365
+ }
6321
6366
  function authoredPropsAtPointer(root, pointer) {
6322
6367
  let node = root;
6323
6368
  for (const token of pointer.split("/").slice(1)) {
@@ -6335,8 +6380,9 @@ function authoredPropsAtPointer(root, pointer) {
6335
6380
  }
6336
6381
  return asRecord(asRecord(node)?.props);
6337
6382
  }
6338
- function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slideHeightIn, ctx, theme, slideBackground, analyzedTextPaths, analyzedContentPaths, paletteTokens, authoredPropsAt, authoredPath, addFact) {
6383
+ function addSlideFacts(roots, slidePath, renderedIndex, page, grid, slideWidthIn, slideHeightIn, ctx, theme, slideBackground, analyzedTextPaths, analyzedContentPaths, paletteTokens, authoredPropsAt, statedPropsAt, authoredPath, addFact, ownsProps = () => true) {
6339
6384
  const nodes = [];
6385
+ const richNodes = [];
6340
6386
  const surfaces = [];
6341
6387
  const boxes = [];
6342
6388
  const contentNodes = [];
@@ -6346,6 +6392,7 @@ function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slid
6346
6392
  root.component,
6347
6393
  root.path,
6348
6394
  nodes,
6395
+ richNodes,
6349
6396
  surfaces,
6350
6397
  boxes,
6351
6398
  contentNodes,
@@ -6359,20 +6406,30 @@ function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slid
6359
6406
  if (fact) addFact(fact);
6360
6407
  }
6361
6408
  boxes.forEach((node, boxIndex) => {
6362
- const box = resolveBox(node.props, grid, slideWidthIn, slideHeightIn);
6409
+ const box = imageBox(
6410
+ node,
6411
+ resolveBox(node.props, grid, slideWidthIn, slideHeightIn),
6412
+ statedPropsAt
6413
+ );
6363
6414
  if (!isCompleteBox(box)) return;
6415
+ const onSlide = /^\/children\/\d+\/children\/\d+$/.test(node.path) && authoredPath(node.path) === node.path;
6416
+ const authored = onSlide ? authoredPropsAt(node.path) : void 0;
6364
6417
  addFact({
6365
6418
  id: `pptx:box:${renderedIndex}:${boxIndex}:${node.path}`,
6366
6419
  kind: "pptx/box",
6367
6420
  path: node.path,
6368
6421
  slidePath,
6422
+ nodePath: node.path,
6369
6423
  componentName: node.componentName,
6370
6424
  order: node.order,
6371
6425
  opaque: node.opaque,
6372
6426
  xPt: box.xPt,
6373
6427
  yPt: box.yPt,
6374
6428
  widthPt: box.widthPt,
6375
- heightPt: box.heightPt
6429
+ heightPt: box.heightPt,
6430
+ ...typeof authored?.x === "number" && typeof authored?.y === "number" && authored.grid === void 0 && {
6431
+ authoredPositionIn: { x: authored.x, y: authored.y }
6432
+ }
6376
6433
  });
6377
6434
  });
6378
6435
  const surfaceBoxes = surfaces.flatMap((surface) => {
@@ -6382,7 +6439,7 @@ function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slid
6382
6439
  let bodyWords = 0;
6383
6440
  nodes.forEach((node, nodeIndex) => {
6384
6441
  const typography = resolveTypography(node.props, ctx);
6385
- if (typography.styleName !== "title" && typography.styleName !== "subtitle") {
6442
+ if (!NOT_BODY_STYLES.has(typography.styleName ?? "")) {
6386
6443
  bodyWords += node.text.split(/\s+/).filter(Boolean).length;
6387
6444
  }
6388
6445
  if (analyzedTextPaths.has(node.path)) return;
@@ -6476,13 +6533,18 @@ function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slid
6476
6533
  }
6477
6534
  }
6478
6535
  const colorHex = typeof node.props.color === "string" ? resolveColor(node.props.color, theme)?.toUpperCase() : void 0;
6536
+ const stated = statedPropsAt(node.path);
6537
+ const setSize = stated && node.props.fit !== void 0 ? resolveTypography(stated, ctx).fontSize : void 0;
6479
6538
  addFact({
6480
6539
  id: `pptx:text:${renderedIndex}:${nodeIndex}:${node.path}`,
6481
6540
  kind: "pptx/text",
6482
6541
  path: node.path,
6483
6542
  slidePath,
6543
+ ...page === void 0 ? { slideHidden: true } : { page },
6544
+ nodePath: node.path,
6484
6545
  text: node.text,
6485
6546
  fontSizePt: typography.fontSize,
6547
+ ...setSize !== void 0 && setSize !== typography.fontSize && { fitFromPt: setSize },
6486
6548
  lineSpacingPt: typography.lineSpacing,
6487
6549
  paraSpaceBeforePt: typography.paraSpaceBefore,
6488
6550
  paraSpaceAfterPt: typography.paraSpaceAfter,
@@ -6491,19 +6553,64 @@ function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slid
6491
6553
  ...boxYPt !== void 0 && { boxYPt },
6492
6554
  ...boxWidthPt !== void 0 && boxWidthPt > 0 && { boxWidthPt },
6493
6555
  ...boxHeightPt !== void 0 && boxHeightPt > 0 && { boxHeightPt },
6556
+ insetsPt: textInsetsPt(node.props, node.componentName),
6494
6557
  verticalAlign: node.props.valign === "middle" || node.props.valign === "bottom" ? node.props.valign : "top",
6495
6558
  align: horizontalAlign(node.props, ctx),
6496
6559
  rotationDeg: asNumber(node.props.rotate) ?? 0,
6497
6560
  bold: typography.bold,
6498
- ...asNumber(node.props.fontSize) !== void 0 && {
6561
+ // Only a size the author wrote has a pointer: component defaults and
6562
+ // the fit pass also write `fontSize` into the processed props, onto a
6563
+ // member the document does not have.
6564
+ ...asNumber(node.props.fontSize) !== void 0 && asNumber(authoredPropsAt(node.path)?.fontSize) !== void 0 && {
6499
6565
  sizePath: `${node.path}/props/fontSize`
6500
6566
  },
6501
6567
  generated: authoredPath(node.path) !== node.path,
6568
+ ownsProps: ownsProps(node.path),
6502
6569
  autoFit: node.props.h === void 0 && gridPos === void 0,
6503
6570
  ...colorHex !== void 0 && { colorHex },
6504
6571
  ...!backgroundUnknown && backgroundHexes.length > 0 && { backgroundHexes }
6505
6572
  });
6506
6573
  });
6574
+ richNodes.forEach((node, nodeIndex) => {
6575
+ if (analyzedTextPaths.has(node.path)) return;
6576
+ analyzedTextPaths.add(node.path);
6577
+ const typography = resolveTypography(node.props, ctx);
6578
+ const box = resolveBox(node.props, grid, slideWidthIn, slideHeightIn);
6579
+ const runs = node.props.runs.map(asRecord);
6580
+ addFact({
6581
+ id: `pptx:rich-text:${renderedIndex}:${nodeIndex}:${node.path}`,
6582
+ kind: "pptx/rich-text",
6583
+ path: node.path,
6584
+ slidePath,
6585
+ ...page === void 0 ? { slideHidden: true } : { page },
6586
+ nodePath: node.path,
6587
+ text: node.text,
6588
+ ...typography.styleName && { styleName: typography.styleName },
6589
+ runSizes: runs.flatMap((run, index) => {
6590
+ if (!run || typeof run.text !== "string" || run.text.trim() === "")
6591
+ return [];
6592
+ const own = asNumber(run.fontSize);
6593
+ return [
6594
+ {
6595
+ fontSizePt: own ?? typography.fontSize,
6596
+ ...own !== void 0 && {
6597
+ sizePath: `${node.path}/props/runs/${index}/fontSize`
6598
+ }
6599
+ }
6600
+ ];
6601
+ }),
6602
+ generated: authoredPath(node.path) !== node.path,
6603
+ ...box.xPt !== void 0 && { boxXPt: box.xPt },
6604
+ ...box.yPt !== void 0 && { boxYPt: box.yPt },
6605
+ ...box.widthPt !== void 0 && box.widthPt > 0 && {
6606
+ boxWidthPt: box.widthPt
6607
+ },
6608
+ ...box.heightPt !== void 0 && box.heightPt > 0 && {
6609
+ boxHeightPt: box.heightPt
6610
+ },
6611
+ rotationDeg: asNumber(node.props.rotate) ?? 0
6612
+ });
6613
+ });
6507
6614
  const CHROME_STYLES = /* @__PURE__ */ new Set(["footer", "tracker", "source", "caption"]);
6508
6615
  const carriesContent = (box) => box.componentName !== "shape" || typeof box.props.text === "string" && box.props.text.trim() !== "";
6509
6616
  addFact({
@@ -6526,23 +6633,31 @@ function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slid
6526
6633
  const stretched = authored.w !== void 0 && authored.h !== void 0 && !["contain", "cover"].includes(
6527
6634
  String(asRecord(authored.sizing)?.type ?? "")
6528
6635
  );
6636
+ const widthPt = slideWidthIn * 72;
6637
+ const heightPt = slideHeightIn * 72;
6638
+ const bleed = isCompleteBox(resolved) && (resolved.xPt <= BLEED_TOLERANCE_PT && resolved.xPt + resolved.widthPt >= widthPt - BLEED_TOLERANCE_PT || resolved.yPt <= BLEED_TOLERANCE_PT && resolved.yPt + resolved.heightPt >= heightPt - BLEED_TOLERANCE_PT);
6529
6639
  addFact({
6530
6640
  id: `pptx:image:${renderedIndex}:${box.path}`,
6531
6641
  kind: "pptx/image",
6532
6642
  path: box.path,
6643
+ slidePath,
6644
+ bleed,
6533
6645
  ...typeof alt === "string" && alt.trim() !== "" && { alt },
6534
6646
  ...stretched && isCompleteBox(resolved) && resolved.heightPt > 0 && {
6535
6647
  drawnRatio: resolved.widthPt / resolved.heightPt
6536
6648
  },
6537
- ...natural !== void 0 && { naturalRatio: natural }
6649
+ ...natural !== void 0 && { naturalRatio: natural },
6650
+ ...stretched && typeof authored.w === "number" && typeof authored.h === "number" && {
6651
+ authoredSizeIn: { w: authored.w, h: authored.h }
6652
+ }
6538
6653
  });
6539
6654
  }
6540
6655
  const bullets = bulletItems(box.props);
6541
- if (bullets.length > 1)
6656
+ if (bullets.length > 0)
6542
6657
  addFact({
6543
6658
  id: `pptx:bullets:${renderedIndex}:${box.path}`,
6544
6659
  kind: "pptx/bullets",
6545
- path: box.path,
6660
+ path: slotOrNode(box.path, authoredPath),
6546
6661
  slidePath,
6547
6662
  items: bullets.length,
6548
6663
  longestWords: Math.max(
@@ -6555,7 +6670,7 @@ function preparePptxQualityDocument(document, options = {}) {
6555
6670
  const facts = [];
6556
6671
  const provenance = {};
6557
6672
  let sourceMap = {};
6558
- const authoredPath = (path4) => toAuthoredPointer(sourceMap, path4);
6673
+ const authoredPath = (path4) => toAuthoredPointer(sourceMap, path4, options.expanded?.pluginOutputs);
6559
6674
  const addFact = (raw) => {
6560
6675
  const fact = {
6561
6676
  ...raw,
@@ -6602,12 +6717,12 @@ function preparePptxQualityDocument(document, options = {}) {
6602
6717
  });
6603
6718
  });
6604
6719
  const warnings = options.warnings ?? [];
6605
- const context = resolveThemeContext(document, {
6720
+ const context = options.context ?? resolveThemeContext(document, {
6606
6721
  customThemes: options.customThemes,
6607
6722
  fonts: options.fonts,
6608
6723
  warnings
6609
6724
  });
6610
- const expanded = expandPptxBlocks(context.document, context.theme);
6725
+ const expanded = options.expanded ?? expandPptxBlocks(context.document, context.theme);
6611
6726
  sourceMap = expanded.sourceMap;
6612
6727
  const processed = processPresentation(expanded.document, {
6613
6728
  theme: context.theme,
@@ -6701,10 +6816,17 @@ function preparePptxQualityDocument(document, options = {}) {
6701
6816
  (value) => `#${resolveColor(value, processed.theme)}`
6702
6817
  ) ?? SERIES_COLOR_TOKENS.filter((token) => paletteHexes[token] !== void 0);
6703
6818
  const authoredPropsAt = (pointer) => authoredPropsAtPointer(document, authoredPath(pointer));
6819
+ const pluginOutputs = options.expanded?.pluginOutputs ?? [];
6820
+ const ownsProps = (pointer) => !pluginOutputs.some(
6821
+ (prefix) => pointer === prefix || pointer.startsWith(`${prefix}/`)
6822
+ ) && authoredNameAtPointer(document, authoredPath(pointer)) !== "block";
6823
+ const statedPropsAt = (pointer) => authoredPropsAtPointer(expanded.document, pointer);
6824
+ let exportedPages = 0;
6704
6825
  processed.slides.forEach((slide, renderedIndex) => {
6705
6826
  const authoredIndex = slideIndexes[renderedIndex];
6706
6827
  if (authoredIndex === void 0) return;
6707
6828
  const slidePath = `/children/${authoredIndex}`;
6829
+ const page = slide.hidden === true ? void 0 : exportedPages++;
6708
6830
  const roots = slide.components.map(
6709
6831
  (component, index) => ({
6710
6832
  component,
@@ -6715,6 +6837,7 @@ function preparePptxQualityDocument(document, options = {}) {
6715
6837
  roots,
6716
6838
  slidePath,
6717
6839
  renderedIndex,
6840
+ page,
6718
6841
  processed.grid,
6719
6842
  processed.slideWidth,
6720
6843
  processed.slideHeight,
@@ -6732,8 +6855,10 @@ function preparePptxQualityDocument(document, options = {}) {
6732
6855
  analyzedContentPaths,
6733
6856
  paletteTokens,
6734
6857
  authoredPropsAt,
6858
+ statedPropsAt,
6735
6859
  authoredPath,
6736
- addFact
6860
+ addFact,
6861
+ ownsProps
6737
6862
  );
6738
6863
  });
6739
6864
  for (const budget of blockSlotBudgets(context.document, expanded.blocks)) {
@@ -6743,10 +6868,10 @@ function preparePptxQualityDocument(document, options = {}) {
6743
6868
  ...budget
6744
6869
  });
6745
6870
  }
6746
- const slotTextNodes = textNodesBySlot(processed, slideIndexes, sourceMap);
6871
+ const slotNodes = nodesBySlot(processed, slideIndexes, sourceMap);
6747
6872
  for (const role of blockSlotRoles(context.document, expanded.blocks)) {
6748
- const present = role.value !== void 0 && role.value !== null && role.value !== "" && role.value !== false && (!Array.isArray(role.value) || role.value.length > 0);
6749
- const bound = slotTextNodes.get(role.path);
6873
+ const present = role.value !== void 0 && role.value !== null && role.value !== false && (typeof role.value !== "string" || role.value.trim() !== "") && (!Array.isArray(role.value) || role.value.length > 0);
6874
+ const bound = slotNodes.get(role.path);
6750
6875
  let measured;
6751
6876
  if (bound && typeof bound.props.text === "string") {
6752
6877
  const typography = resolveTypography(bound.props, ctx);
@@ -6779,10 +6904,35 @@ function preparePptxQualityDocument(document, options = {}) {
6779
6904
  slot: role.slot,
6780
6905
  role: role.role,
6781
6906
  present,
6907
+ ...bound && { nodePath: bound.path },
6782
6908
  ...typeof role.value === "string" && { text: role.value },
6783
6909
  ...measured
6784
6910
  });
6785
6911
  }
6912
+ const footerType = typeof processed.theme.chrome?.confidentialFooter?.type === "string" ? processed.theme.chrome.confidentialFooter.type : "footer";
6913
+ processed.slides.forEach((slide, renderedIndex) => {
6914
+ const authoredIndex = slideIndexes[renderedIndex];
6915
+ if (authoredIndex === void 0) return;
6916
+ const slidePath = `/children/${authoredIndex}`;
6917
+ const onSlide = (fact) => "slidePath" in fact && fact.slidePath === slidePath;
6918
+ const texts = facts.filter(
6919
+ (fact) => (fact.kind === "pptx/text" || fact.kind === "pptx/rich-text") && onSlide(fact)
6920
+ );
6921
+ addFact({
6922
+ id: `pptx:slide-chrome:${slidePath}`,
6923
+ kind: "pptx/slide-chrome",
6924
+ path: slidePath,
6925
+ index: renderedIndex,
6926
+ ...slide.hidden === true && { hidden: true },
6927
+ blocks: expanded.blocks.filter(
6928
+ (pointer) => pointer.startsWith(`${slidePath}/children/`)
6929
+ ).length,
6930
+ pageNumber: texts.some((fact) => fact.text.includes("{PAGE_NUMBER}")),
6931
+ footer: texts.some((fact) => fact.styleName === footerType) || facts.some(
6932
+ (fact) => fact.kind === "pptx/chrome-slot" && fact.role === "footer" && fact.present && onSlide(fact)
6933
+ )
6934
+ });
6935
+ });
6786
6936
  return {
6787
6937
  format: "pptx",
6788
6938
  model: {
@@ -6805,14 +6955,18 @@ function preparePptxQualityDocument(document, options = {}) {
6805
6955
  }
6806
6956
  };
6807
6957
  }
6808
- function textNodesBySlot(processed, slideIndexes, sourceMap) {
6958
+ function nodesBySlot(processed, slideIndexes, sourceMap) {
6809
6959
  const found = /* @__PURE__ */ new Map();
6810
6960
  const visit = (component, path4) => {
6811
6961
  if (component.enabled === false) return;
6812
6962
  if (component.name === "text") {
6813
6963
  const origin = toAuthoredPointer(sourceMap, `${path4}/props/text`);
6814
6964
  if (origin !== `${path4}/props/text` && !found.has(origin))
6815
- found.set(origin, { props: asRecord(component.props) ?? {} });
6965
+ found.set(origin, { props: asRecord(component.props) ?? {}, path: path4 });
6966
+ } else {
6967
+ const origin = toAuthoredPointer(sourceMap, path4);
6968
+ if (origin !== path4 && origin.includes("/props/slots/") && !found.has(origin))
6969
+ found.set(origin, { props: asRecord(component.props) ?? {}, path: path4 });
6816
6970
  }
6817
6971
  (component.children ?? []).forEach(
6818
6972
  (child, index) => visit(child, `${path4}/children/${index}`)
@@ -7571,6 +7725,8 @@ import {
7571
7725
  // src/quality/rules.ts
7572
7726
  import {
7573
7727
  chartInfoDesignFindings,
7728
+ configurationLabel,
7729
+ configurationSource,
7574
7730
  DEFAULT_MAXIMUM_CHART_SERIES,
7575
7731
  DEFAULT_MAXIMUM_PIE_SLICES,
7576
7732
  fontCountFinding,
@@ -7711,19 +7867,24 @@ var pptxMinimumFontRule = {
7711
7867
  );
7712
7868
  return textFacts(facts).filter((fact) => fact.fontSizePt < minimum).map((fact) => ({
7713
7869
  message: `Effective font size is ${fact.fontSizePt}pt \u2014 unreadable on a projected slide.`,
7714
- path: `${fact.path}/props`,
7870
+ // Text a definition or a plugin generated reports at the invocation
7871
+ // the author wrote, whose props are the block's or the plugin's: no
7872
+ // fontSize of theirs to lift, so no patch.
7873
+ path: fact.ownsProps ? `${fact.path}/props` : fact.path,
7715
7874
  suggestion: `Use at least ${minimum}pt; captions rarely work below 10pt.`,
7716
7875
  context: { fontSize: fact.fontSizePt, threshold: minimum },
7717
7876
  evidence: { actual: fact.fontSizePt, expected: minimum, unit: "pt" },
7718
7877
  // `add` replaces an existing member, so this lifts an explicit
7719
7878
  // fontSize and overrides an inherited style value alike.
7720
- fixes: [
7721
- {
7722
- op: "add",
7723
- path: `${fact.path}/props/fontSize`,
7724
- value: minimum
7725
- }
7726
- ]
7879
+ ...fact.ownsProps && {
7880
+ fixes: [
7881
+ {
7882
+ op: "add",
7883
+ path: `${fact.path}/props/fontSize`,
7884
+ value: minimum
7885
+ }
7886
+ ]
7887
+ }
7727
7888
  }));
7728
7889
  }
7729
7890
  };
@@ -8225,7 +8386,8 @@ var pptxOffCanvasRule = {
8225
8386
  code: QUALITY_CODES.OFF_CANVAS,
8226
8387
  category: "integrity",
8227
8388
  defaultSeverity: "warning",
8228
- defaultCertainty: "measured",
8389
+ // The box is measured; the ink inside it is the width model's estimate.
8390
+ defaultCertainty: "estimated",
8229
8391
  formats: ["pptx"],
8230
8392
  defaultParameters: {
8231
8393
  tolerancePt: DEFAULT_OFF_CANVAS_TOLERANCE_PT,
@@ -8326,13 +8488,67 @@ var pptxRequiredChromeRule = {
8326
8488
  if (required.length === 0) return [];
8327
8489
  return facts.filter(
8328
8490
  (fact) => fact.kind === "pptx/chrome-slot"
8329
- ).filter((fact) => required.includes(fact.role) && !fact.present).map((fact) => ({
8330
- path: fact.path,
8331
- relatedPaths: [fact.invocation],
8332
- message: `${fact.block} states no ${fact.role} in its "${fact.slot}" slot; the ${profile?.id ?? "selected"} profile expects one on every ${fact.block}.`,
8333
- suggestion: `Fill the "${fact.slot}" slot. The theme already styles it.`,
8334
- context: { block: fact.block, slot: fact.slot, role: fact.role }
8335
- }));
8491
+ ).filter((fact) => required.includes(fact.role) && !fact.present).map((fact) => {
8492
+ const source = configurationSource(configuration, "required");
8493
+ return {
8494
+ path: fact.path,
8495
+ relatedPaths: [fact.invocation],
8496
+ message: `${fact.block} states no ${fact.role} in its "${fact.slot}" slot; ${configurationLabel(source, profile)} expects one on every ${fact.block}.`,
8497
+ suggestion: `Fill the "${fact.slot}" slot. The theme already styles it.`,
8498
+ context: { block: fact.block, slot: fact.slot, role: fact.role },
8499
+ evidence: {
8500
+ actual: "empty",
8501
+ expected: fact.role,
8502
+ values: { source, required }
8503
+ }
8504
+ };
8505
+ });
8506
+ }
8507
+ };
8508
+ var SLIDE_CHROME_PARTS = ["pageNumber", "footer"];
8509
+ var pptxSlideFooterRule = {
8510
+ id: "pptx/slide-footer",
8511
+ description: "A block-built slide without the running chrome a profile or policy expects: a page number, a footer line. Off until one names parts.",
8512
+ code: QUALITY_CODES.CHROME_MISSING,
8513
+ category: "consistency",
8514
+ defaultSeverity: "warning",
8515
+ defaultCertainty: "deterministic",
8516
+ formats: ["pptx"],
8517
+ defaultParameters: { required: [], fromSlide: 1 },
8518
+ evaluate: ({ facts, configuration, profile }) => {
8519
+ const required = stringListParameter(
8520
+ configuration.parameters,
8521
+ "required"
8522
+ ).filter(
8523
+ (part) => SLIDE_CHROME_PARTS.includes(part)
8524
+ );
8525
+ if (required.length === 0) return [];
8526
+ const from = numberParameter(configuration.parameters, "fromSlide", 1);
8527
+ return facts.filter(
8528
+ (fact) => fact.kind === "pptx/slide-chrome" && fact.blocks > 0 && fact.hidden !== true && fact.index >= from
8529
+ ).flatMap((fact) => {
8530
+ const missing = required.filter((part) => !fact[part]);
8531
+ if (missing.length === 0) return [];
8532
+ const parts = missing.map((part) => part === "pageNumber" ? "page number" : "footer").join(" or ");
8533
+ const source = configurationSource(
8534
+ configuration,
8535
+ "required",
8536
+ "fromSlide"
8537
+ );
8538
+ return [
8539
+ {
8540
+ path: fact.path,
8541
+ message: `Slide ${fact.index + 1} carries no ${parts}; ${configurationLabel(source, profile)} expects one on every slide a block builds from slide ${from + 1} on.`,
8542
+ suggestion: "Build the slide with a block that draws the running footer \u2014 the house blocks set {PAGE_NUMBER} / {PAGE_COUNT} in the footer role \u2014 or add that text to the definition the slide invokes.",
8543
+ context: { slide: fact.index, missing },
8544
+ evidence: {
8545
+ actual: required.filter((part) => fact[part]),
8546
+ expected: required,
8547
+ values: { source, fromSlide: from }
8548
+ }
8549
+ }
8550
+ ];
8551
+ });
8336
8552
  }
8337
8553
  };
8338
8554
  var pptxActionTitleRule = {
@@ -8365,23 +8581,49 @@ var pptxActionTitleRule = {
8365
8581
  }
8366
8582
  };
8367
8583
  var TITLE_DRIFT_TOLERANCE_PT = 2;
8584
+ var FIRST_BASELINE_EM = 0.8;
8585
+ var TITLE_PLACEMENT_LINES = 2;
8368
8586
  var PPTX_TYPE_VOCABULARY = {
8369
8587
  subject: "deck",
8370
8588
  keepTo: "Keep to the theme styles \u2014 title, heading, body, label, statistic \u2014 and drop the ad-hoc sizes."
8371
8589
  };
8590
+ var PPTX_SLIDE_TYPE_VOCABULARY = {
8591
+ subject: "slide",
8592
+ keepTo: "Give the slide fewer levels: let the title, the body and one supporting size carry it, in the theme styles."
8593
+ };
8372
8594
  function pptxThemeFact(facts) {
8373
8595
  return facts.find(
8374
8596
  (fact) => fact.kind === "pptx/theme"
8375
8597
  );
8376
8598
  }
8377
8599
  function paintedSizes(facts) {
8378
- return textFacts(facts).map((fact) => ({
8379
- path: fact.path,
8380
- ...fact.styleName !== void 0 && { role: fact.styleName },
8381
- fontSizePt: fact.fontSizePt,
8382
- ...fact.sizePath !== void 0 && { sizePath: fact.sizePath },
8383
- generated: fact.generated
8384
- }));
8600
+ const sizes = [];
8601
+ for (const fact of textFacts(facts)) {
8602
+ if (fact.slideHidden) continue;
8603
+ sizes.push({
8604
+ path: fact.path,
8605
+ slidePath: fact.slidePath,
8606
+ ...fact.styleName !== void 0 && { role: fact.styleName },
8607
+ fontSizePt: fact.fontSizePt,
8608
+ ...fact.sizePath !== void 0 && { sizePath: fact.sizePath },
8609
+ generated: fact.generated
8610
+ });
8611
+ }
8612
+ for (const fact of facts) {
8613
+ if (fact.kind !== "pptx/rich-text") continue;
8614
+ const rich = fact;
8615
+ if (rich.slideHidden) continue;
8616
+ for (const run of rich.runSizes)
8617
+ sizes.push({
8618
+ path: rich.path,
8619
+ slidePath: rich.slidePath,
8620
+ ...rich.styleName !== void 0 && { role: rich.styleName },
8621
+ fontSizePt: run.fontSizePt,
8622
+ ...run.sizePath !== void 0 && { sizePath: run.sizePath },
8623
+ generated: rich.generated
8624
+ });
8625
+ }
8626
+ return sizes;
8385
8627
  }
8386
8628
  var pptxTypeScaleRule = {
8387
8629
  id: "pptx/type-scale",
@@ -8407,21 +8649,56 @@ var pptxTypeScaleRule = {
8407
8649
  };
8408
8650
  var pptxSizeCountRule = {
8409
8651
  id: "pptx/size-count",
8410
- description: "More distinct text sizes than maximumSizes allows, blocks included. Off until a profile or policy enables it.",
8652
+ description: "More distinct text sizes than maximumSizes allows across the deck, or than maximumSizesPerSlide allows on one slide (0: no per-slide ceiling), blocks and runs included. Off until a profile or policy enables it.",
8411
8653
  code: QUALITY_CODES.TYPE_SIZE_COUNT,
8412
8654
  category: "consistency",
8413
8655
  defaultSeverity: "warning",
8414
8656
  defaultCertainty: "deterministic",
8415
8657
  formats: ["pptx"],
8416
8658
  defaultEnabled: false,
8417
- defaultParameters: { maximumSizes: 8 },
8418
- evaluate: ({ facts, configuration, profile }) => sizeCountFinding(
8419
- paintedSizes(facts),
8420
- numberParameter(configuration.parameters, "maximumSizes", 8),
8421
- pptxThemeFact(facts)?.path ?? "/props",
8422
- profile?.id,
8423
- PPTX_TYPE_VOCABULARY
8424
- )
8659
+ defaultParameters: { maximumSizes: 8, maximumSizesPerSlide: 0 },
8660
+ evaluate: ({ facts, configuration, profile }) => {
8661
+ const sizes = paintedSizes(facts);
8662
+ const setBy = (parameter) => {
8663
+ const source = configurationSource(configuration, parameter);
8664
+ return { source, label: configurationLabel(source, profile) };
8665
+ };
8666
+ const findings = sizeCountFinding(
8667
+ sizes,
8668
+ numberParameter(configuration.parameters, "maximumSizes", 8),
8669
+ pptxThemeFact(facts)?.path ?? "/props",
8670
+ setBy("maximumSizes"),
8671
+ PPTX_TYPE_VOCABULARY
8672
+ ).map((finding) => ({
8673
+ ...finding,
8674
+ context: { ...finding.context, scope: "deck" }
8675
+ }));
8676
+ const perSlide = numberParameter(
8677
+ configuration.parameters,
8678
+ "maximumSizesPerSlide",
8679
+ 0
8680
+ );
8681
+ if (perSlide <= 0) return findings;
8682
+ const bySlide = /* @__PURE__ */ new Map();
8683
+ for (const size of sizes)
8684
+ bySlide.set(size.slidePath, [
8685
+ ...bySlide.get(size.slidePath) ?? [],
8686
+ size
8687
+ ]);
8688
+ for (const [slidePath, onSlide] of bySlide)
8689
+ for (const finding of sizeCountFinding(
8690
+ onSlide,
8691
+ perSlide,
8692
+ slidePath,
8693
+ setBy("maximumSizesPerSlide"),
8694
+ PPTX_SLIDE_TYPE_VOCABULARY
8695
+ ))
8696
+ findings.push({
8697
+ ...finding,
8698
+ context: { ...finding.context, scope: "slide" }
8699
+ });
8700
+ return findings;
8701
+ }
8425
8702
  };
8426
8703
  var pptxRoleDriftRule = {
8427
8704
  id: "pptx/role-drift",
@@ -8437,53 +8714,107 @@ var pptxRoleDriftRule = {
8437
8714
  return theme ? roleDriftFindings(paintedSizes(facts), theme.roleSizesPt) : [];
8438
8715
  }
8439
8716
  };
8440
- function titleGroups(facts, styles) {
8441
- const texts = textFacts(facts).filter(
8442
- (fact) => fact.boxXPt !== void 0 && fact.boxYPt !== void 0
8717
+ var EDGE_NAMES = {
8718
+ left: "left edge",
8719
+ center: "centre line",
8720
+ right: "right edge"
8721
+ };
8722
+ function laidOutTitle(fact, path4, order) {
8723
+ if (fact.boxXPt === void 0 || fact.boxYPt === void 0 || fact.boxWidthPt === void 0 || fact.rotationDeg % 360 !== 0)
8724
+ return void 0;
8725
+ const [top, right, bottom, left] = fact.insetsPt;
8726
+ const innerWidth = Math.max(1, fact.boxWidthPt - left - right);
8727
+ const { heightPt, lines } = estimateTextHeightPt(
8728
+ fact.text,
8729
+ innerWidth,
8730
+ fact.fontSizePt,
8731
+ fact.lineSpacingPt,
8732
+ fact.paraSpaceBeforePt,
8733
+ fact.paraSpaceAfterPt
8734
+ );
8735
+ let textTop = fact.boxYPt + top;
8736
+ if (fact.boxHeightPt !== void 0 && !fact.autoFit) {
8737
+ const room = fact.boxHeightPt - top - bottom - heightPt;
8738
+ if (fact.verticalAlign === "middle") textTop += room / 2;
8739
+ else if (fact.verticalAlign === "bottom") textTop += room;
8740
+ }
8741
+ const align = fact.align === "justify" ? "left" : fact.align;
8742
+ const edgePt = align === "center" ? fact.boxXPt + left + innerWidth / 2 : align === "right" ? fact.boxXPt + fact.boxWidthPt - right : fact.boxXPt + left;
8743
+ const style = fact.styleName ?? "unstyled";
8744
+ const setSizePt = fact.fitFromPt ?? fact.fontSizePt;
8745
+ return {
8746
+ fact,
8747
+ path: path4,
8748
+ kind: `${style}|${setSizePt}|${align}`,
8749
+ style,
8750
+ setSizePt,
8751
+ align,
8752
+ lines,
8753
+ baselinePt: textTop + FIRST_BASELINE_EM * fact.fontSizePt,
8754
+ edgePt,
8755
+ order
8756
+ };
8757
+ }
8758
+ function deckTitles(facts, styles) {
8759
+ const texts = textFacts(facts).filter((fact) => !fact.slideHidden);
8760
+ const slideIndex = (fact) => Number(/^\/children\/(\d+)/.exec(fact.slidePath)?.[1] ?? 0);
8761
+ const order = new Map(
8762
+ texts.map((fact, index) => [fact, slideIndex(fact) * 1e4 + index])
8443
8763
  );
8444
- const groups = /* @__PURE__ */ new Map();
8764
+ const byNode = new Map(texts.map((fact) => [fact.nodePath, fact]));
8765
+ const titles = [];
8445
8766
  const claimed = /* @__PURE__ */ new Set();
8446
- const push = (key, fact) => {
8767
+ const add = (fact, path4) => {
8447
8768
  if (claimed.has(fact)) return;
8448
8769
  claimed.add(fact);
8449
- groups.set(key, [...groups.get(key) ?? [], fact]);
8770
+ const title = laidOutTitle(fact, path4, order.get(fact));
8771
+ if (title) titles.push(title);
8450
8772
  };
8451
- for (const slot of facts) {
8452
- if (slot.kind !== "pptx/chrome-slot" || slot.role !== "actionTitle")
8453
- continue;
8454
- const chrome = slot;
8455
- if (chrome.text === void 0) continue;
8456
- const painted = texts.find(
8457
- (fact) => fact.slidePath === chrome.slidePath && fact.text === chrome.text
8458
- );
8459
- if (painted) push(chrome.block, painted);
8460
- }
8773
+ for (const slot of facts)
8774
+ if (slot.kind === "pptx/chrome-slot" && slot.role === "actionTitle" && slot.nodePath !== void 0) {
8775
+ const painted = byNode.get(slot.nodePath);
8776
+ if (painted) add(painted, slot.path);
8777
+ }
8461
8778
  for (const fact of texts)
8462
8779
  if (fact.styleName !== void 0 && styles.includes(fact.styleName))
8463
- push(`style:${fact.styleName}`, fact);
8464
- return groups;
8465
- }
8466
- function prevailing(values) {
8467
- const counts = /* @__PURE__ */ new Map();
8468
- for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
8469
- let best;
8470
- let bestCount = 0;
8471
- for (const value of values) {
8472
- const count = counts.get(value);
8473
- if (count > bestCount) {
8474
- best = value;
8475
- bestCount = count;
8780
+ add(fact, fact.path);
8781
+ return titles.sort((a, b) => a.order - b.order);
8782
+ }
8783
+ function titlePlacements(titles, tolerance) {
8784
+ const lands = (a, b) => Math.abs(a.baselinePt - b.baselinePt) <= tolerance && Math.abs(a.edgePt - b.edgePt) <= tolerance;
8785
+ const byKind = /* @__PURE__ */ new Map();
8786
+ for (const title of titles)
8787
+ byKind.set(title.kind, [...byKind.get(title.kind) ?? [], title]);
8788
+ const placements = [];
8789
+ for (const kind of byKind.values()) {
8790
+ let pending = kind;
8791
+ while (pending.length > 0) {
8792
+ let reference = pending[0];
8793
+ let most = 0;
8794
+ for (const candidate of pending) {
8795
+ const count = pending.filter((other) => lands(other, candidate)).length;
8796
+ if (count > most) {
8797
+ reference = candidate;
8798
+ most = count;
8799
+ }
8800
+ }
8801
+ const reach = TITLE_PLACEMENT_LINES * reference.fact.lineSpacingPt;
8802
+ const members = pending.filter(
8803
+ (title) => Math.abs(title.baselinePt - reference.baselinePt) <= reach && Math.abs(title.edgePt - reference.edgePt) <= reach
8804
+ );
8805
+ placements.push({ reference, members });
8806
+ pending = pending.filter((title) => !members.includes(title));
8476
8807
  }
8477
8808
  }
8478
- return best;
8809
+ return placements;
8479
8810
  }
8480
8811
  var pptxTitleDriftRule = {
8481
8812
  id: "pptx/title-drift",
8482
- description: "A slide title away from the left edge or baseline the deck\u2019s other titles of that kind share. Off until a profile or policy enables it.",
8813
+ description: "A slide title whose laid-out first baseline or aligned edge is away from where the deck\u2019s other titles of that kind land. Off until a profile or policy enables it.",
8483
8814
  code: QUALITY_CODES.TITLE_DRIFT,
8484
8815
  category: "consistency",
8485
8816
  defaultSeverity: "warning",
8486
- defaultCertainty: "deterministic",
8817
+ defaultCertainty: "estimated",
8487
8818
  formats: ["pptx"],
8488
8819
  defaultEnabled: false,
8489
8820
  defaultParameters: {
@@ -8498,40 +8829,53 @@ var pptxTitleDriftRule = {
8498
8829
  TITLE_DRIFT_TOLERANCE_PT
8499
8830
  );
8500
8831
  const findings = [];
8501
- for (const [kind, titles] of titleGroups(facts, styles)) {
8502
- if (titles.length < 2) continue;
8503
- const axes = [
8504
- {
8505
- key: "left edge",
8506
- prop: "boxXPt",
8507
- value: prevailing(titles.map((fact) => fact.boxXPt))
8508
- },
8509
- {
8510
- key: "baseline",
8511
- prop: "boxYPt",
8512
- value: prevailing(titles.map((fact) => fact.boxYPt))
8513
- }
8514
- ];
8515
- for (const fact of titles) {
8516
- const off = axes.filter(
8517
- (axis) => axis.value !== void 0 && Math.abs(fact[axis.prop] - axis.value) > tolerance
8832
+ for (const { reference, members } of titlePlacements(
8833
+ deckTitles(facts, styles),
8834
+ tolerance
8835
+ )) {
8836
+ if (members.length < 2) continue;
8837
+ const agreeing = members.filter(
8838
+ (title) => Math.abs(title.baselinePt - reference.baselinePt) <= tolerance && Math.abs(title.edgePt - reference.edgePt) <= tolerance
8839
+ );
8840
+ for (const title of members) {
8841
+ if (agreeing.includes(title)) continue;
8842
+ const edge = EDGE_NAMES[title.align];
8843
+ const off = [
8844
+ {
8845
+ axis: "first baseline",
8846
+ actual: title.baselinePt,
8847
+ expected: reference.baselinePt
8848
+ },
8849
+ { axis: edge, actual: title.edgePt, expected: reference.edgePt }
8850
+ ].filter(
8851
+ (entry) => Math.abs(entry.actual - entry.expected) > tolerance
8518
8852
  );
8519
- if (off.length === 0) continue;
8520
8853
  const [first] = off;
8854
+ const size = title.fact.fontSizePt !== reference.fact.fontSizePt ? ` The fit pass sets it at ${title.fact.fontSizePt}pt, where they set at ${reference.fact.fontSizePt}pt.` : "";
8521
8855
  findings.push({
8522
- path: fact.path,
8523
- message: `This ${kind.startsWith("style:") ? kind.slice(6) : kind} title sits at ${off.map((axis) => `${axis.key} ${round(fact[axis.prop])}pt`).join(", ")}; the deck's others share ${off.map((axis) => `${axis.key} ${round(axis.value)}pt`).join(", ")}.`,
8524
- suggestion: "Place every title of one kind with the same block or the same coordinates, so the deck holds one line down the page.",
8856
+ path: title.path,
8857
+ relatedPaths: agreeing.map((other) => other.path),
8858
+ message: `This ${title.style} title lays out at ${off.map((entry) => `${entry.axis} ${round(entry.actual)}pt`).join(", ")}; the deck's other titles of that kind land at ${off.map((entry) => `${entry.axis} ${round(entry.expected)}pt`).join(", ")}.${size}`,
8859
+ suggestion: "Place every title of one kind with the same block or the same coordinates, anchor them alike, and keep each short enough to set at the size the others keep.",
8525
8860
  context: {
8526
- kind,
8527
- axes: off.map((axis) => axis.key),
8528
- slide: fact.slidePath
8861
+ style: title.style,
8862
+ setSizePt: title.setSizePt,
8863
+ align: title.align,
8864
+ axes: off.map((entry) => entry.axis),
8865
+ slide: title.fact.slidePath
8529
8866
  },
8530
8867
  evidence: {
8531
- actual: round(fact[first.prop]),
8532
- expected: round(first.value),
8868
+ summary: "estimated first baseline and aligned edge",
8869
+ actual: round(first.actual),
8870
+ expected: round(first.expected),
8533
8871
  unit: "pt",
8534
- values: { axis: first.key, source: "profile" }
8872
+ values: {
8873
+ axis: first.axis,
8874
+ lines: title.lines,
8875
+ fontSizePt: title.fact.fontSizePt,
8876
+ verticalAlign: title.fact.verticalAlign,
8877
+ source: configurationSource(configuration, "tolerancePt")
8878
+ }
8535
8879
  }
8536
8880
  });
8537
8881
  }
@@ -8584,7 +8928,9 @@ var pptxBulletRule = {
8584
8928
  actual: bullets.items,
8585
8929
  expected: maximumBullets,
8586
8930
  unit: "bullets",
8587
- values: { source: "profile" }
8931
+ values: {
8932
+ source: configurationSource(configuration, "maximumBullets")
8933
+ }
8588
8934
  }
8589
8935
  });
8590
8936
  if (maximumWords > 0 && bullets.longestWords > maximumWords)
@@ -8598,16 +8944,39 @@ var pptxBulletRule = {
8598
8944
  actual: bullets.longestWords,
8599
8945
  expected: maximumWords,
8600
8946
  unit: "words",
8601
- values: { source: "profile" }
8947
+ values: {
8948
+ source: configurationSource(
8949
+ configuration,
8950
+ "maximumWordsPerBullet"
8951
+ )
8952
+ }
8602
8953
  }
8603
8954
  });
8604
8955
  }
8605
8956
  return findings;
8606
8957
  }
8607
8958
  };
8959
+ function safeAreaFixes(fact, canvas, safe) {
8960
+ const position = fact.authoredPositionIn;
8961
+ if (position === void 0 || fact.widthPt > canvas.widthPt - 2 * safe || fact.heightPt > canvas.heightPt - 2 * safe)
8962
+ return [];
8963
+ const clamp = (valuePt, sizePt, extentPt) => {
8964
+ const low = Math.ceil(safe / 72 * 1e3) / 1e3;
8965
+ const high = Math.floor((extentPt - safe - sizePt) / 72 * 1e3) / 1e3;
8966
+ return Math.min(Math.max(valuePt / 72, low), high);
8967
+ };
8968
+ const fixes = [];
8969
+ const x = clamp(fact.xPt, fact.widthPt, canvas.widthPt);
8970
+ const y = clamp(fact.yPt, fact.heightPt, canvas.heightPt);
8971
+ if (Math.abs(x - position.x) > 5e-4)
8972
+ fixes.push({ op: "replace", path: `${fact.path}/props/x`, value: x });
8973
+ if (Math.abs(y - position.y) > 5e-4)
8974
+ fixes.push({ op: "replace", path: `${fact.path}/props/y`, value: y });
8975
+ return fixes;
8976
+ }
8608
8977
  var pptxSafeAreaRule = {
8609
8978
  id: "pptx/safe-area",
8610
- description: "Content outside the theme\u2019s safe area that is neither chrome nor a full bleed. Off until a profile or policy enables it.",
8979
+ description: "Content outside the theme\u2019s safe area that is neither chrome (a tracker, footer, source or logo) nor a full bleed. Off until a profile or policy enables it.",
8611
8980
  code: QUALITY_CODES.SAFE_AREA,
8612
8981
  category: "composition",
8613
8982
  defaultSeverity: "warning",
@@ -8626,20 +8995,16 @@ var pptxSafeAreaRule = {
8626
8995
  );
8627
8996
  const safe = canvas?.safeAreaPt;
8628
8997
  if (canvas === void 0 || safe === void 0 || safe <= 0) return [];
8629
- const chromePaths = new Set(
8998
+ const chromeNodes = new Set(
8630
8999
  facts.filter(
8631
- (fact) => fact.kind === "pptx/chrome-slot" && (fact.role === "tracker" || fact.role === "footer")
8632
- ).map((fact) => fact.path)
8633
- );
8634
- const chromeStyles = /* @__PURE__ */ new Set(["footer", "tracker"]);
8635
- const styledChrome = new Set(
8636
- textFacts(facts).filter(
8637
- (fact) => fact.styleName !== void 0 && chromeStyles.has(fact.styleName)
8638
- ).map((fact) => fact.path)
9000
+ (fact) => fact.kind === "pptx/chrome-slot" && (fact.role === "tracker" || fact.role === "footer" || fact.role === "source" || fact.role === "logo")
9001
+ ).flatMap((fact) => fact.nodePath ? [fact.nodePath] : [])
8639
9002
  );
8640
- return facts.filter((fact) => fact.kind === "pptx/box").filter(
8641
- (fact) => !chromePaths.has(fact.path) && !styledChrome.has(fact.path)
8642
- ).flatMap((fact) => {
9003
+ const chromeStyles = /* @__PURE__ */ new Set(["footer", "tracker", "source"]);
9004
+ for (const fact of facts)
9005
+ if ((fact.kind === "pptx/text" || fact.kind === "pptx/rich-text") && fact.styleName !== void 0 && chromeStyles.has(fact.styleName))
9006
+ chromeNodes.add(fact.nodePath);
9007
+ return facts.filter((fact) => fact.kind === "pptx/box").filter((fact) => !chromeNodes.has(fact.nodePath)).flatMap((fact) => {
8643
9008
  const right = fact.xPt + fact.widthPt;
8644
9009
  const bottom = fact.yPt + fact.heightPt;
8645
9010
  const spansWidth = fact.xPt <= tolerance && right >= canvas.widthPt - tolerance;
@@ -8658,6 +9023,7 @@ var pptxSafeAreaRule = {
8658
9023
  right - (canvas.widthPt - safe),
8659
9024
  bottom - (canvas.heightPt - safe)
8660
9025
  );
9026
+ const fixes = safeAreaFixes(fact, canvas, safe);
8661
9027
  return [
8662
9028
  {
8663
9029
  path: fact.path,
@@ -8669,7 +9035,8 @@ var pptxSafeAreaRule = {
8669
9035
  expected: 0,
8670
9036
  unit: "pt",
8671
9037
  values: { source: "theme" }
8672
- }
9038
+ },
9039
+ ...fixes.length > 0 && { fixes }
8673
9040
  }
8674
9041
  ];
8675
9042
  });
@@ -8688,8 +9055,8 @@ var pptxSlideTitleRule = {
8688
9055
  evaluate: ({ facts, configuration }) => {
8689
9056
  const styles = stringListParameter(configuration.parameters, "titleStyles");
8690
9057
  const titledSlides = /* @__PURE__ */ new Set();
8691
- for (const fact of textFacts(facts))
8692
- if (fact.styleName !== void 0 && styles.includes(fact.styleName))
9058
+ for (const fact of facts)
9059
+ if ((fact.kind === "pptx/text" || fact.kind === "pptx/rich-text") && fact.styleName !== void 0 && styles.includes(fact.styleName))
8693
9060
  titledSlides.add(fact.slidePath);
8694
9061
  for (const fact of facts)
8695
9062
  if (fact.kind === "pptx/chrome-slot" && fact.role === "actionTitle" && fact.present)
@@ -8705,11 +9072,33 @@ var pptxSlideTitleRule = {
8705
9072
  actual: 0,
8706
9073
  expected: 1,
8707
9074
  unit: "titles",
8708
- values: { source: "profile" }
9075
+ values: { source: configurationSource(configuration) }
8709
9076
  }
8710
9077
  }));
8711
9078
  }
8712
9079
  };
9080
+ var pptxFigureLabelRule = {
9081
+ id: "pptx/figure-label",
9082
+ description: "An image with no alt text, a background that bleeds off the slide aside. Off until a profile or policy enables it.",
9083
+ code: QUALITY_CODES.FIGURE_UNLABELLED,
9084
+ category: "accessibility",
9085
+ defaultSeverity: "warning",
9086
+ defaultCertainty: "deterministic",
9087
+ formats: ["pptx"],
9088
+ defaultEnabled: false,
9089
+ evaluate: ({ facts }) => {
9090
+ const unlabelled = /* @__PURE__ */ new Map();
9091
+ for (const fact of facts)
9092
+ if (fact.kind === "pptx/image" && fact.alt === void 0 && !fact.bleed)
9093
+ unlabelled.set(fact.path, fact);
9094
+ return [...unlabelled.values()].map((fact) => ({
9095
+ path: fact.path,
9096
+ message: "This image carries no alt text, so nothing in the deck says what it shows to a reader who cannot see it.",
9097
+ suggestion: "Write `alt` on the image: what it shows, in a sentence. A decorative background can bleed off the slide instead.",
9098
+ context: { slidePath: fact.slidePath }
9099
+ }));
9100
+ }
9101
+ };
8713
9102
  var pptxImageAspectRule = {
8714
9103
  id: "pptx/image-aspect",
8715
9104
  description: "An image drawn at an aspect the asset does not have, where the asset can be read from the document.",
@@ -8732,7 +9121,20 @@ var pptxImageAspectRule = {
8732
9121
  {
8733
9122
  path: fact.path,
8734
9123
  drawn: fact.drawnRatio,
8735
- natural: fact.naturalRatio
9124
+ natural: fact.naturalRatio,
9125
+ ...fact.authoredSizeIn && {
9126
+ sides: {
9127
+ width: {
9128
+ path: `${fact.path}/props/w`,
9129
+ value: fact.authoredSizeIn.w
9130
+ },
9131
+ height: {
9132
+ path: `${fact.path}/props/h`,
9133
+ value: fact.authoredSizeIn.h
9134
+ },
9135
+ decimals: 3
9136
+ }
9137
+ }
8736
9138
  },
8737
9139
  "slide",
8738
9140
  tolerance
@@ -8758,6 +9160,7 @@ var PPTX_QUALITY_RULES = {
8758
9160
  pptxOffCanvasRule,
8759
9161
  pptxSlotBudgetRule,
8760
9162
  pptxRequiredChromeRule,
9163
+ pptxSlideFooterRule,
8761
9164
  pptxActionTitleRule,
8762
9165
  pptxTypeScaleRule,
8763
9166
  pptxSizeCountRule,
@@ -8766,6 +9169,7 @@ var PPTX_QUALITY_RULES = {
8766
9169
  pptxBulletRule,
8767
9170
  pptxSafeAreaRule,
8768
9171
  pptxSlideTitleRule,
9172
+ pptxFigureLabelRule,
8769
9173
  pptxImageAspectRule
8770
9174
  ]
8771
9175
  };
@@ -8791,15 +9195,23 @@ var PPTX_QUALITY_PROFILES = {
8791
9195
  "consulting-deck": {
8792
9196
  id: "consulting-deck",
8793
9197
  formats: ["pptx"],
8794
- description: "Consulting readout: every content slide leads with a two-line action title, every chart carries a takeaway and a source, content stays inside the theme\u2019s safe area, bullets stay under five and under twelve words, every size is on the theme scale with at most eight in play, and titles of one kind hold one line.",
9198
+ description: "Consulting readout: every content slide leads with a two-line action title, every chart carries a takeaway and a source, every slide a block builds after the cover carries its page number, content stays inside the theme\u2019s safe area, a box holds at most five bullets of at most twelve words, every figure carries alt text, every size is on the theme scale with at most nine in the deck and six on a slide, and titles of one kind hold one line.",
8795
9199
  rules: {
8796
9200
  "pptx/required-chrome": {
8797
9201
  parameters: { required: ["takeaway", "source"] }
8798
9202
  },
9203
+ "pptx/slide-footer": { parameters: { required: ["pageNumber"] } },
8799
9204
  "pptx/action-title": { parameters: { maxLines: 2 } },
8800
9205
  "pptx/slide-density": { parameters: { maximumBodyWords: 90 } },
8801
9206
  "pptx/type-scale": { enabled: true },
8802
- "pptx/size-count": { enabled: true, parameters: { maximumSizes: 8 } },
9207
+ // Measured 2026-09-15, runs included: the house deck paints nine sizes
9208
+ // (9, 10, 12, 14, 16, 18, 28, 32, 40) and six on its busiest slide, and
9209
+ // so did every one of the 22 verification decks built from its blocks.
9210
+ // A size past these is one no house block paints.
9211
+ "pptx/size-count": {
9212
+ enabled: true,
9213
+ parameters: { maximumSizes: 9, maximumSizesPerSlide: 6 }
9214
+ },
8803
9215
  "pptx/role-drift": { enabled: true },
8804
9216
  "pptx/title-drift": {
8805
9217
  enabled: true,
@@ -8809,7 +9221,8 @@ var PPTX_QUALITY_PROFILES = {
8809
9221
  parameters: { maximumBullets: 5, maximumWordsPerBullet: 12 }
8810
9222
  },
8811
9223
  "pptx/safe-area": { enabled: true },
8812
- "pptx/slide-title": { enabled: true }
9224
+ "pptx/slide-title": { enabled: true },
9225
+ "pptx/figure-label": { enabled: true }
8813
9226
  }
8814
9227
  }
8815
9228
  };
@@ -9080,97 +9493,152 @@ function createBuilderImpl(state) {
9080
9493
  newState
9081
9494
  );
9082
9495
  }
9083
- async function generate(document, options) {
9084
- try {
9085
- let internalDocument = document;
9086
- const renderer = options?.renderer ?? state.renderer ?? internalDocument.renderer;
9087
- const validationDocument = renderer === void 0 ? internalDocument : { ...internalDocument, renderer };
9088
- const validationOptions = {
9089
- ...state.validation,
9090
- ...options?.validation
9091
- };
9092
- if (validationOptions.enabled !== false) {
9093
- const result = validatePresentation(
9094
- validationDocument,
9095
- state.components,
9096
- { allowUnknownFields: validationOptions.allowUnknownFields }
9496
+ async function expandPresentation(document, options) {
9497
+ let internalDocument = document;
9498
+ const renderer = options?.renderer ?? state.renderer ?? internalDocument.renderer;
9499
+ const validationDocument = renderer === void 0 ? internalDocument : { ...internalDocument, renderer };
9500
+ const validationOptions = {
9501
+ ...state.validation,
9502
+ ...options?.validation
9503
+ };
9504
+ if (validationOptions.enabled !== false) {
9505
+ const result = validatePresentation(
9506
+ validationDocument,
9507
+ state.components,
9508
+ { allowUnknownFields: validationOptions.allowUnknownFields }
9509
+ );
9510
+ if (!result.valid) {
9511
+ throw new ComponentValidationError3(result.errors, internalDocument);
9512
+ }
9513
+ } else if (!internalDocument || internalDocument.name !== "pptx") {
9514
+ throw new Error("Top-level component must be a pptx component");
9515
+ }
9516
+ const warnings = [];
9517
+ const context = resolveThemeContext(internalDocument, {
9518
+ customThemes: state.customThemes,
9519
+ fonts: state.fonts,
9520
+ warnings,
9521
+ defaultThemeName: typeof state.theme === "string" ? state.theme : void 0,
9522
+ resolveNamedTheme: (name, authored) => state.customThemes?.[name] ?? (authored && hasPptxTheme(name) ? getPptxTheme(name) : void 0) ?? (typeof state.theme === "object" && state.theme !== null ? state.theme : getPptxTheme(name))
9523
+ });
9524
+ const modedRoot = context.document;
9525
+ const resolvedTheme = context.theme;
9526
+ const validateEmitted = validationOptions.enabled === false ? void 0 : (emitted, componentLabel, parentName) => {
9527
+ let validationDocument2;
9528
+ if (parentName === "pptx") {
9529
+ validationDocument2 = {
9530
+ ...modedRoot,
9531
+ ...renderer !== void 0 ? { renderer } : {},
9532
+ children: emitted
9533
+ };
9534
+ } else if (parentName === "slide") {
9535
+ validationDocument2 = {
9536
+ ...modedRoot,
9537
+ ...renderer !== void 0 ? { renderer } : {},
9538
+ children: [{ name: "slide", props: {}, children: emitted }]
9539
+ };
9540
+ } else {
9541
+ return;
9542
+ }
9543
+ const result = validatePresentation(
9544
+ validationDocument2,
9545
+ state.components,
9546
+ { allowUnknownFields: validationOptions.allowUnknownFields }
9547
+ );
9548
+ if (!result.valid) {
9549
+ throw new ComponentValidationError3(
9550
+ result.errors.map((error) => ({
9551
+ ...error,
9552
+ message: `custom component '${componentLabel}' emitted invalid output \u2014 ${error.message}`
9553
+ })),
9554
+ emitted
9097
9555
  );
9098
- if (!result.valid) {
9099
- throw new ComponentValidationError3(result.errors, internalDocument);
9556
+ }
9557
+ };
9558
+ const expanded = await expandPptxBlocksWithPlugins(
9559
+ modedRoot,
9560
+ resolvedTheme,
9561
+ new Set(componentMap.keys()),
9562
+ pluginRenderer(warnings, resolvedTheme, validateEmitted)
9563
+ );
9564
+ const processedDocument = expanded.document;
9565
+ if (validationOptions.enabled !== false) {
9566
+ const result = validatePresentation(
9567
+ {
9568
+ ...processedDocument,
9569
+ ...renderer !== void 0 ? { renderer } : {}
9570
+ },
9571
+ [],
9572
+ {
9573
+ allowUnknownFields: validationOptions.allowUnknownFields
9100
9574
  }
9101
- } else if (!internalDocument || internalDocument.name !== "pptx") {
9102
- throw new Error("Top-level component must be a pptx component");
9575
+ );
9576
+ if (!result.valid) {
9577
+ throw new ComponentValidationError3(
9578
+ result.errors.map((error) => ({
9579
+ ...error,
9580
+ message: `expanded plugin output failed validation \u2014 ${error.message}`
9581
+ })),
9582
+ processedDocument
9583
+ );
9103
9584
  }
9104
- const warnings = [];
9105
- const context = resolveThemeContext(internalDocument, {
9585
+ }
9586
+ return { context, expanded, warnings, renderer };
9587
+ }
9588
+ async function prepareQuality(document, options) {
9589
+ const { context, expanded, warnings, renderer } = await expandPresentation(
9590
+ document,
9591
+ options
9592
+ );
9593
+ const prepared = preparePptxQualityDocument(
9594
+ document,
9595
+ {
9596
+ context,
9597
+ expanded,
9106
9598
  customThemes: state.customThemes,
9107
9599
  fonts: state.fonts,
9108
- warnings,
9109
- defaultThemeName: typeof state.theme === "string" ? state.theme : void 0,
9110
- resolveNamedTheme: (name, authored) => state.customThemes?.[name] ?? (authored && hasPptxTheme(name) ? getPptxTheme(name) : void 0) ?? (typeof state.theme === "object" && state.theme !== null ? state.theme : getPptxTheme(name))
9111
- });
9112
- const modedRoot = context.document;
9113
- const resolvedTheme = context.theme;
9114
- const validateEmitted = validationOptions.enabled === false ? void 0 : (emitted, componentLabel, parentName) => {
9115
- let validationDocument2;
9116
- if (parentName === "pptx") {
9117
- validationDocument2 = {
9118
- ...modedRoot,
9119
- ...renderer !== void 0 ? { renderer } : {},
9120
- children: emitted
9121
- };
9122
- } else if (parentName === "slide") {
9123
- validationDocument2 = {
9124
- ...modedRoot,
9125
- ...renderer !== void 0 ? { renderer } : {},
9126
- children: [{ name: "slide", props: {}, children: emitted }]
9127
- };
9128
- } else {
9129
- return;
9130
- }
9131
- const result = validatePresentation(
9132
- validationDocument2,
9133
- state.components,
9134
- { allowUnknownFields: validationOptions.allowUnknownFields }
9600
+ services: state.services,
9601
+ ...renderer !== void 0 && { renderer },
9602
+ warnings
9603
+ }
9604
+ );
9605
+ return { prepared, warnings };
9606
+ }
9607
+ async function generateFromPrepared(options) {
9608
+ const { prepared } = options;
9609
+ const { document: processedDocument, theme, processed } = prepared.model;
9610
+ const warnings = [];
9611
+ const renderer = options.renderer ?? state.renderer ?? prepared.renderer;
9612
+ const hasHighcharts = containsHighcharts(processed);
9613
+ const resolvedFonts = await resolveDocumentFonts(
9614
+ processedDocument,
9615
+ theme,
9616
+ warnings,
9617
+ state.fonts,
9618
+ hasHighcharts
9619
+ );
9620
+ const chartFonts = hasHighcharts ? toChartFontFaces2(resolvedFonts) : [];
9621
+ const buffer = await runWithBaseDir(
9622
+ options.baseDir ?? state.baseDir,
9623
+ () => renderProcessedViaIr(processed, warnings, {
9624
+ renderer,
9625
+ services: state.services,
9626
+ deterministic: options.deterministic ?? state.packaging.deterministic,
9627
+ generatedAt: options.generatedAt ?? state.packaging.generatedAt,
9628
+ ...chartFonts.length > 0 ? { chartFonts } : {}
9629
+ })
9630
+ );
9631
+ return { buffer, warnings };
9632
+ }
9633
+ async function generate(document, options) {
9634
+ try {
9635
+ if (options?.prepared)
9636
+ return await generateFromPrepared(
9637
+ options
9135
9638
  );
9136
- if (!result.valid) {
9137
- throw new ComponentValidationError3(
9138
- result.errors.map((error) => ({
9139
- ...error,
9140
- message: `custom component '${componentLabel}' emitted invalid output \u2014 ${error.message}`
9141
- })),
9142
- emitted
9143
- );
9144
- }
9145
- };
9146
- const expanded = await expandPptxBlocksWithPlugins(
9147
- modedRoot,
9148
- resolvedTheme,
9149
- new Set(componentMap.keys()),
9150
- pluginRenderer(warnings, resolvedTheme, validateEmitted)
9151
- );
9639
+ const { context, expanded, warnings, renderer } = await expandPresentation(document, options);
9640
+ const resolvedTheme = context.theme;
9152
9641
  const processedDocument = expanded.document;
9153
- if (validationOptions.enabled !== false) {
9154
- const result = validatePresentation(
9155
- {
9156
- ...processedDocument,
9157
- ...renderer !== void 0 ? { renderer } : {}
9158
- },
9159
- [],
9160
- {
9161
- allowUnknownFields: validationOptions.allowUnknownFields
9162
- }
9163
- );
9164
- if (!result.valid) {
9165
- throw new ComponentValidationError3(
9166
- result.errors.map((error) => ({
9167
- ...error,
9168
- message: `expanded plugin output failed validation \u2014 ${error.message}`
9169
- })),
9170
- processedDocument
9171
- );
9172
- }
9173
- }
9174
9642
  const processed = processPresentation(processedDocument, {
9175
9643
  theme: resolvedTheme,
9176
9644
  services: state.services,
@@ -9269,6 +9737,7 @@ function createBuilderImpl(state) {
9269
9737
  generate,
9270
9738
  generateBuffer: generate,
9271
9739
  generateFile,
9740
+ prepareQuality,
9272
9741
  getComponentNames,
9273
9742
  validate,
9274
9743
  generateSchema,