@json-to-office/core-pptx 6.1.0 → 6.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1014,8 +1014,8 @@ async function repairSvgRasterFallbacks(zip, warnings) {
1014
1014
  const source = zip.file(item.svgPart);
1015
1015
  if (!source) throw new Error(`missing part ${item.svgPart}`);
1016
1016
  const svg = await source.async("string");
1017
- const probe2 = new Resvg(svg);
1018
- const fitTo = resolveFitTo(probe2.width / probe2.height, item.cx, item.cy);
1017
+ const probe3 = new Resvg(svg);
1018
+ const fitTo = resolveFitTo(probe3.width / probe3.height, item.cx, item.cy);
1019
1019
  const key = `${fitTo.mode}:${fitTo.value}
1020
1020
  ${svg}`;
1021
1021
  let png = rendered.get(key);
@@ -4331,7 +4331,12 @@ import {
4331
4331
  DEFAULT_PPTX_RENDERER_ID as DEFAULT_PPTX_RENDERER_ID2,
4332
4332
  SEMANTIC_COLOR_NAMES as SEMANTIC_COLOR_NAMES2
4333
4333
  } from "@json-to-office/shared-pptx";
4334
- import { designColors as designColors2 } from "@json-to-office/shared";
4334
+ import {
4335
+ designCanvas as designCanvas2,
4336
+ designColors as designColors2,
4337
+ typeScaleSizes
4338
+ } from "@json-to-office/shared";
4339
+ import probe2 from "probe-image-size";
4335
4340
 
4336
4341
  // src/core/generationContext.ts
4337
4342
  import { applyExportMode } from "@json-to-office/shared";
@@ -5970,6 +5975,30 @@ function horizontalAlign(props, ctx) {
5970
5975
  const styled = typeof props.style === "string" ? ctx.styles[props.style]?.align : void 0;
5971
5976
  return styled && HORIZONTAL_ALIGNMENTS.has(styled) ? styled : "left";
5972
5977
  }
5978
+ function bulletItems(props) {
5979
+ const runs = Array.isArray(props.runs) ? props.runs : void 0;
5980
+ const whole = props.bullet !== void 0 && props.bullet !== false;
5981
+ if (runs) {
5982
+ const bulleted = runs.map(asRecord).filter(
5983
+ (run) => run !== void 0 && (whole || run.bullet !== void 0 && run.bullet !== false)
5984
+ ).map((run) => typeof run.text === "string" ? run.text : "");
5985
+ return bulleted.filter((item) => item.trim() !== "");
5986
+ }
5987
+ if (!whole || typeof props.text !== "string") return [];
5988
+ return props.text.split("\n").filter((line) => line.trim() !== "");
5989
+ }
5990
+ function readablePptxRatio(props) {
5991
+ const source = typeof props.base64 === "string" ? props.base64 : typeof props.path === "string" && props.path.startsWith("data:") ? props.path : void 0;
5992
+ if (source === void 0) return void 0;
5993
+ const comma = source.indexOf(",");
5994
+ if (comma < 0) return void 0;
5995
+ try {
5996
+ const size = probe2.sync(Buffer.from(source.slice(comma + 1), "base64"));
5997
+ return size && size.height > 0 ? size.width / size.height : void 0;
5998
+ } catch {
5999
+ return void 0;
6000
+ }
6001
+ }
5973
6002
  function themeContext(theme) {
5974
6003
  return {
5975
6004
  styles: theme.styles ?? {},
@@ -6309,7 +6338,7 @@ function authoredPropsAtPointer(root, pointer) {
6309
6338
  }
6310
6339
  return asRecord(asRecord(node)?.props);
6311
6340
  }
6312
- function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slideHeightIn, ctx, theme, slideBackground, analyzedTextPaths, analyzedContentPaths, paletteTokens, authoredPropsAt, addFact) {
6341
+ function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slideHeightIn, ctx, theme, slideBackground, analyzedTextPaths, analyzedContentPaths, paletteTokens, authoredPropsAt, authoredPath, addFact) {
6313
6342
  const nodes = [];
6314
6343
  const surfaces = [];
6315
6344
  const boxes = [];
@@ -6469,17 +6498,61 @@ function addSlideFacts(roots, slidePath, renderedIndex, grid, slideWidthIn, slid
6469
6498
  align: horizontalAlign(node.props, ctx),
6470
6499
  rotationDeg: asNumber(node.props.rotate) ?? 0,
6471
6500
  bold: typography.bold,
6501
+ ...asNumber(node.props.fontSize) !== void 0 && {
6502
+ sizePath: `${node.path}/props/fontSize`
6503
+ },
6504
+ generated: authoredPath(node.path) !== node.path,
6472
6505
  autoFit: node.props.h === void 0 && gridPos === void 0,
6473
6506
  ...colorHex !== void 0 && { colorHex },
6474
6507
  ...!backgroundUnknown && backgroundHexes.length > 0 && { backgroundHexes }
6475
6508
  });
6476
6509
  });
6510
+ const CHROME_STYLES = /* @__PURE__ */ new Set(["footer", "tracker", "source", "caption"]);
6511
+ const carriesContent = (box) => box.componentName !== "shape" || typeof box.props.text === "string" && box.props.text.trim() !== "";
6477
6512
  addFact({
6478
6513
  id: `pptx:slide:${renderedIndex}:${slidePath}`,
6479
6514
  kind: "pptx/slide",
6480
6515
  path: slidePath,
6481
- bodyWords
6516
+ bodyWords,
6517
+ contentBoxes: boxes.filter(
6518
+ (box) => ["text", "shape", "table", "chart", "highcharts", "image"].includes(
6519
+ box.componentName
6520
+ ) && carriesContent(box) && !CHROME_STYLES.has(String(box.props.style ?? ""))
6521
+ ).length
6482
6522
  });
6523
+ for (const box of boxes) {
6524
+ if (box.componentName === "image") {
6525
+ const resolved = resolveBox(box.props, grid, slideWidthIn, slideHeightIn);
6526
+ const alt = box.props.alt;
6527
+ const natural = readablePptxRatio(box.props);
6528
+ const authored = authoredPropsAt(box.path) ?? box.props;
6529
+ const stretched = authored.w !== void 0 && authored.h !== void 0 && !["contain", "cover"].includes(
6530
+ String(asRecord(authored.sizing)?.type ?? "")
6531
+ );
6532
+ addFact({
6533
+ id: `pptx:image:${renderedIndex}:${box.path}`,
6534
+ kind: "pptx/image",
6535
+ path: box.path,
6536
+ ...typeof alt === "string" && alt.trim() !== "" && { alt },
6537
+ ...stretched && isCompleteBox(resolved) && resolved.heightPt > 0 && {
6538
+ drawnRatio: resolved.widthPt / resolved.heightPt
6539
+ },
6540
+ ...natural !== void 0 && { naturalRatio: natural }
6541
+ });
6542
+ }
6543
+ const bullets = bulletItems(box.props);
6544
+ if (bullets.length > 1)
6545
+ addFact({
6546
+ id: `pptx:bullets:${renderedIndex}:${box.path}`,
6547
+ kind: "pptx/bullets",
6548
+ path: box.path,
6549
+ slidePath,
6550
+ items: bullets.length,
6551
+ longestWords: Math.max(
6552
+ ...bullets.map((item) => item.split(/\s+/).filter(Boolean).length)
6553
+ )
6554
+ });
6555
+ }
6483
6556
  }
6484
6557
  function preparePptxQualityDocument(document, options = {}) {
6485
6558
  const facts = [];
@@ -6501,17 +6574,6 @@ function preparePptxQualityDocument(document, options = {}) {
6501
6574
  };
6502
6575
  };
6503
6576
  const props = asRecord(document.props) ?? {};
6504
- addFact({
6505
- id: "pptx:canvas",
6506
- kind: "pptx/canvas",
6507
- path: "/props",
6508
- ...asNumber(props.slideWidth) !== void 0 && {
6509
- widthIn: asNumber(props.slideWidth)
6510
- },
6511
- ...asNumber(props.slideHeight) !== void 0 && {
6512
- heightIn: asNumber(props.slideHeight)
6513
- }
6514
- });
6515
6577
  collectPlaceholders(document).forEach((occurrence, index) => {
6516
6578
  addFact({
6517
6579
  id: `pptx:placeholder:${index}:${occurrence.path}`,
@@ -6523,7 +6585,9 @@ function preparePptxQualityDocument(document, options = {}) {
6523
6585
  excerpt: occurrence.match.excerpt
6524
6586
  });
6525
6587
  });
6526
- collectColorLiterals(document).forEach((literal, index) => {
6588
+ const content = { ...document, props: { ...props } };
6589
+ delete content.props.theme;
6590
+ collectColorLiterals(content).forEach((literal, index) => {
6527
6591
  addFact({
6528
6592
  id: `pptx:color:${index}:${literal.path}`,
6529
6593
  kind: "pptx/color",
@@ -6532,7 +6596,7 @@ function preparePptxQualityDocument(document, options = {}) {
6532
6596
  hex: literal.hex
6533
6597
  });
6534
6598
  });
6535
- collectFontFamilies(document).forEach((use, index) => {
6599
+ collectFontFamilies(content).forEach((use, index) => {
6536
6600
  addFact({
6537
6601
  id: `pptx:font:${index}:${use.path}`,
6538
6602
  kind: "pptx/font-family",
@@ -6556,6 +6620,26 @@ function preparePptxQualityDocument(document, options = {}) {
6556
6620
  warnings
6557
6621
  });
6558
6622
  const ctx = themeContext(processed.theme);
6623
+ const safeAreaIn = asNumber(
6624
+ processed.theme.spacing?.canvas?.[designCanvas2("pptx", {
6625
+ width: processed.slideWidth,
6626
+ height: processed.slideHeight
6627
+ })]?.safeAreaIn
6628
+ );
6629
+ addFact({
6630
+ id: "pptx:canvas",
6631
+ kind: "pptx/canvas",
6632
+ path: "/props",
6633
+ ...asNumber(props.slideWidth) !== void 0 && {
6634
+ widthIn: asNumber(props.slideWidth)
6635
+ },
6636
+ ...asNumber(props.slideHeight) !== void 0 && {
6637
+ heightIn: asNumber(props.slideHeight)
6638
+ },
6639
+ widthPt: processed.slideWidth * 72,
6640
+ heightPt: processed.slideHeight * 72,
6641
+ ...safeAreaIn !== void 0 && { safeAreaPt: safeAreaIn * 72 }
6642
+ });
6559
6643
  const paletteHexes = {};
6560
6644
  const visualColors = designColors2(
6561
6645
  processed.theme.colors,
@@ -6576,12 +6660,31 @@ function preparePptxQualityDocument(document, options = {}) {
6576
6660
  if (hex)
6577
6661
  paletteHexes[SEMANTIC_COLOR_NAMES2.includes(token) ? token : hex] = hex;
6578
6662
  }
6663
+ const roleSizesPt = {};
6664
+ for (const [name, style] of Object.entries(processed.theme.styles ?? {})) {
6665
+ const size = asNumber(style?.fontSize);
6666
+ if (size !== void 0) roleSizesPt[name] = size;
6667
+ }
6668
+ const defaultFontSize = asNumber(processed.theme.defaults?.fontSize);
6669
+ const scale = processed.theme.typography?.scale?.[designCanvas2("pptx", {
6670
+ width: processed.slideWidth,
6671
+ height: processed.slideHeight
6672
+ })];
6673
+ const typeScalePt = [
6674
+ .../* @__PURE__ */ new Set([
6675
+ ...Object.values(roleSizesPt),
6676
+ ...defaultFontSize === void 0 ? [] : [defaultFontSize],
6677
+ ...scale ? typeScaleSizes(scale) : []
6678
+ ])
6679
+ ].sort((a, b) => a - b);
6579
6680
  addFact({
6580
6681
  id: "pptx:theme",
6581
6682
  kind: "pptx/theme",
6582
6683
  path: "/props",
6583
6684
  themeName: processed.theme.name,
6584
6685
  paletteHexes,
6686
+ typeScalePt,
6687
+ roleSizesPt,
6585
6688
  fontFamilies: [
6586
6689
  ...new Set(
6587
6690
  [processed.theme.fonts?.heading, processed.theme.fonts?.body].filter(
@@ -6632,6 +6735,7 @@ function preparePptxQualityDocument(document, options = {}) {
6632
6735
  analyzedContentPaths,
6633
6736
  paletteTokens,
6634
6737
  authoredPropsAt,
6738
+ authoredPath,
6635
6739
  addFact
6636
6740
  );
6637
6741
  });
@@ -6672,6 +6776,9 @@ function preparePptxQualityDocument(document, options = {}) {
6672
6776
  relatedPaths: [role.invocation],
6673
6777
  block: role.block,
6674
6778
  invocation: role.invocation,
6779
+ // The invocation is a slide child, so its slide is the first two
6780
+ // segments of its pointer — the one place that has to know that.
6781
+ slidePath: role.invocation.split("/").slice(0, 3).join("/"),
6675
6782
  slot: role.slot,
6676
6783
  role: role.role,
6677
6784
  present,
@@ -7474,7 +7581,13 @@ import {
7474
7581
  nearestPaletteToken,
7475
7582
  offPaletteFinding,
7476
7583
  placeholderFinding,
7584
+ DEFAULT_IMAGE_ASPECT_TOLERANCE,
7585
+ imageAspectFinding,
7586
+ driftingSizes,
7587
+ offScaleFindings,
7477
7588
  QUALITY_CODES,
7589
+ roleDriftFindings,
7590
+ sizeCountFinding,
7478
7591
  QualityEngine,
7479
7592
  resolveRuleConfiguration,
7480
7593
  tableInfoDesignFindings
@@ -8254,6 +8367,383 @@ var pptxActionTitleRule = {
8254
8367
  }));
8255
8368
  }
8256
8369
  };
8370
+ var TITLE_DRIFT_TOLERANCE_PT = 2;
8371
+ var PPTX_TYPE_VOCABULARY = {
8372
+ subject: "deck",
8373
+ keepTo: "Keep to the theme styles \u2014 title, heading, body, label, statistic \u2014 and drop the ad-hoc sizes."
8374
+ };
8375
+ function pptxThemeFact(facts) {
8376
+ return facts.find(
8377
+ (fact) => fact.kind === "pptx/theme"
8378
+ );
8379
+ }
8380
+ function paintedSizes(facts) {
8381
+ return textFacts(facts).map((fact) => ({
8382
+ path: fact.path,
8383
+ ...fact.styleName !== void 0 && { role: fact.styleName },
8384
+ fontSizePt: fact.fontSizePt,
8385
+ ...fact.sizePath !== void 0 && { sizePath: fact.sizePath },
8386
+ generated: fact.generated
8387
+ }));
8388
+ }
8389
+ var pptxTypeScaleRule = {
8390
+ id: "pptx/type-scale",
8391
+ description: "An authored size the theme never paints: not a style, not a type role, not a step of its scale. Off until a profile or policy enables it.",
8392
+ code: QUALITY_CODES.TYPE_OFF_SCALE,
8393
+ category: "consistency",
8394
+ defaultSeverity: "warning",
8395
+ defaultCertainty: "deterministic",
8396
+ formats: ["pptx"],
8397
+ defaultEnabled: false,
8398
+ evaluate: ({ facts }) => {
8399
+ const theme = pptxThemeFact(facts);
8400
+ if (!theme) return [];
8401
+ const sizes = paintedSizes(facts);
8402
+ return offScaleFindings(
8403
+ sizes,
8404
+ theme.typeScalePt,
8405
+ theme.themeName,
8406
+ PPTX_TYPE_VOCABULARY,
8407
+ driftingSizes(sizes, theme.roleSizesPt)
8408
+ );
8409
+ }
8410
+ };
8411
+ var pptxSizeCountRule = {
8412
+ id: "pptx/size-count",
8413
+ description: "More distinct text sizes than maximumSizes allows, blocks included. Off until a profile or policy enables it.",
8414
+ code: QUALITY_CODES.TYPE_SIZE_COUNT,
8415
+ category: "consistency",
8416
+ defaultSeverity: "warning",
8417
+ defaultCertainty: "deterministic",
8418
+ formats: ["pptx"],
8419
+ defaultEnabled: false,
8420
+ defaultParameters: { maximumSizes: 8 },
8421
+ evaluate: ({ facts, configuration, profile }) => sizeCountFinding(
8422
+ paintedSizes(facts),
8423
+ numberParameter(configuration.parameters, "maximumSizes", 8),
8424
+ pptxThemeFact(facts)?.path ?? "/props",
8425
+ profile?.id,
8426
+ PPTX_TYPE_VOCABULARY
8427
+ )
8428
+ };
8429
+ var pptxRoleDriftRule = {
8430
+ id: "pptx/role-drift",
8431
+ description: "One named style or type role painted at two sizes across the deck; the theme size is the fix. Off until a profile or policy enables it.",
8432
+ code: QUALITY_CODES.TYPE_ROLE_DRIFT,
8433
+ category: "consistency",
8434
+ defaultSeverity: "warning",
8435
+ defaultCertainty: "deterministic",
8436
+ formats: ["pptx"],
8437
+ defaultEnabled: false,
8438
+ evaluate: ({ facts }) => {
8439
+ const theme = pptxThemeFact(facts);
8440
+ return theme ? roleDriftFindings(paintedSizes(facts), theme.roleSizesPt) : [];
8441
+ }
8442
+ };
8443
+ function titleGroups(facts, styles) {
8444
+ const texts = textFacts(facts).filter(
8445
+ (fact) => fact.boxXPt !== void 0 && fact.boxYPt !== void 0
8446
+ );
8447
+ const groups = /* @__PURE__ */ new Map();
8448
+ const claimed = /* @__PURE__ */ new Set();
8449
+ const push = (key, fact) => {
8450
+ if (claimed.has(fact)) return;
8451
+ claimed.add(fact);
8452
+ groups.set(key, [...groups.get(key) ?? [], fact]);
8453
+ };
8454
+ for (const slot of facts) {
8455
+ if (slot.kind !== "pptx/chrome-slot" || slot.role !== "actionTitle")
8456
+ continue;
8457
+ const chrome = slot;
8458
+ if (chrome.text === void 0) continue;
8459
+ const painted = texts.find(
8460
+ (fact) => fact.slidePath === chrome.slidePath && fact.text === chrome.text
8461
+ );
8462
+ if (painted) push(chrome.block, painted);
8463
+ }
8464
+ for (const fact of texts)
8465
+ if (fact.styleName !== void 0 && styles.includes(fact.styleName))
8466
+ push(`style:${fact.styleName}`, fact);
8467
+ return groups;
8468
+ }
8469
+ function prevailing(values) {
8470
+ const counts = /* @__PURE__ */ new Map();
8471
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
8472
+ let best;
8473
+ let bestCount = 0;
8474
+ for (const value of values) {
8475
+ const count = counts.get(value);
8476
+ if (count > bestCount) {
8477
+ best = value;
8478
+ bestCount = count;
8479
+ }
8480
+ }
8481
+ return best;
8482
+ }
8483
+ var pptxTitleDriftRule = {
8484
+ id: "pptx/title-drift",
8485
+ 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.",
8486
+ code: QUALITY_CODES.TITLE_DRIFT,
8487
+ category: "consistency",
8488
+ defaultSeverity: "warning",
8489
+ defaultCertainty: "deterministic",
8490
+ formats: ["pptx"],
8491
+ defaultEnabled: false,
8492
+ defaultParameters: {
8493
+ titleStyles: ["title"],
8494
+ tolerancePt: TITLE_DRIFT_TOLERANCE_PT
8495
+ },
8496
+ evaluate: ({ facts, configuration }) => {
8497
+ const styles = stringListParameter(configuration.parameters, "titleStyles");
8498
+ const tolerance = numberParameter(
8499
+ configuration.parameters,
8500
+ "tolerancePt",
8501
+ TITLE_DRIFT_TOLERANCE_PT
8502
+ );
8503
+ const findings = [];
8504
+ for (const [kind, titles] of titleGroups(facts, styles)) {
8505
+ if (titles.length < 2) continue;
8506
+ const axes = [
8507
+ {
8508
+ key: "left edge",
8509
+ prop: "boxXPt",
8510
+ value: prevailing(titles.map((fact) => fact.boxXPt))
8511
+ },
8512
+ {
8513
+ key: "baseline",
8514
+ prop: "boxYPt",
8515
+ value: prevailing(titles.map((fact) => fact.boxYPt))
8516
+ }
8517
+ ];
8518
+ for (const fact of titles) {
8519
+ const off = axes.filter(
8520
+ (axis) => axis.value !== void 0 && Math.abs(fact[axis.prop] - axis.value) > tolerance
8521
+ );
8522
+ if (off.length === 0) continue;
8523
+ const [first] = off;
8524
+ findings.push({
8525
+ path: fact.path,
8526
+ 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(", ")}.`,
8527
+ suggestion: "Place every title of one kind with the same block or the same coordinates, so the deck holds one line down the page.",
8528
+ context: {
8529
+ kind,
8530
+ axes: off.map((axis) => axis.key),
8531
+ slide: fact.slidePath
8532
+ },
8533
+ evidence: {
8534
+ actual: round(fact[first.prop]),
8535
+ expected: round(first.value),
8536
+ unit: "pt",
8537
+ values: { axis: first.key, source: "profile" }
8538
+ }
8539
+ });
8540
+ }
8541
+ }
8542
+ return findings;
8543
+ }
8544
+ };
8545
+ var round = (value) => Math.round(value * 10) / 10;
8546
+ var pptxBulletRule = {
8547
+ id: "pptx/bullet-density",
8548
+ description: "More bullets in one box, or more words in one bullet, than the profile allows. Off at 0.",
8549
+ code: QUALITY_CODES.BULLET_COUNT,
8550
+ category: "information-design",
8551
+ defaultSeverity: "warning",
8552
+ defaultCertainty: "deterministic",
8553
+ formats: ["pptx"],
8554
+ defaultParameters: { maximumBullets: 0, maximumWordsPerBullet: 0 },
8555
+ evaluate: ({ facts, configuration }) => {
8556
+ const maximumBullets = numberParameter(
8557
+ configuration.parameters,
8558
+ "maximumBullets",
8559
+ 0
8560
+ );
8561
+ const maximumWords = numberParameter(
8562
+ configuration.parameters,
8563
+ "maximumWordsPerBullet",
8564
+ 0
8565
+ );
8566
+ const worst = /* @__PURE__ */ new Map();
8567
+ for (const fact of facts) {
8568
+ if (fact.kind !== "pptx/bullets") continue;
8569
+ const bullets = fact;
8570
+ const held = worst.get(bullets.path);
8571
+ worst.set(bullets.path, {
8572
+ ...bullets,
8573
+ items: Math.max(bullets.items, held?.items ?? 0),
8574
+ longestWords: Math.max(bullets.longestWords, held?.longestWords ?? 0)
8575
+ });
8576
+ }
8577
+ const findings = [];
8578
+ for (const bullets of worst.values()) {
8579
+ if (maximumBullets > 0 && bullets.items > maximumBullets)
8580
+ findings.push({
8581
+ path: bullets.path,
8582
+ code: QUALITY_CODES.BULLET_COUNT,
8583
+ message: `${bullets.items} bullets in one box; a slide carries at most ${maximumBullets}.`,
8584
+ suggestion: "Keep the ones that carry the argument and move the rest to the notes, or split the slide.",
8585
+ context: { items: bullets.items, maximum: maximumBullets },
8586
+ evidence: {
8587
+ actual: bullets.items,
8588
+ expected: maximumBullets,
8589
+ unit: "bullets",
8590
+ values: { source: "profile" }
8591
+ }
8592
+ });
8593
+ if (maximumWords > 0 && bullets.longestWords > maximumWords)
8594
+ findings.push({
8595
+ path: bullets.path,
8596
+ code: QUALITY_CODES.BULLET_LENGTH,
8597
+ message: `The longest bullet here runs to ${bullets.longestWords} words; a bullet states its point in at most ${maximumWords}.`,
8598
+ suggestion: "Cut each bullet to a claim. What is left is the speaker\u2019s to say.",
8599
+ context: { words: bullets.longestWords, maximum: maximumWords },
8600
+ evidence: {
8601
+ actual: bullets.longestWords,
8602
+ expected: maximumWords,
8603
+ unit: "words",
8604
+ values: { source: "profile" }
8605
+ }
8606
+ });
8607
+ }
8608
+ return findings;
8609
+ }
8610
+ };
8611
+ var pptxSafeAreaRule = {
8612
+ id: "pptx/safe-area",
8613
+ description: "Content outside the theme\u2019s safe area that is neither chrome nor a full bleed. Off until a profile or policy enables it.",
8614
+ code: QUALITY_CODES.SAFE_AREA,
8615
+ category: "composition",
8616
+ defaultSeverity: "warning",
8617
+ defaultCertainty: "measured",
8618
+ formats: ["pptx"],
8619
+ defaultEnabled: false,
8620
+ defaultParameters: { tolerancePt: 2 },
8621
+ evaluate: ({ facts, configuration }) => {
8622
+ const tolerance = numberParameter(
8623
+ configuration.parameters,
8624
+ "tolerancePt",
8625
+ 2
8626
+ );
8627
+ const canvas = facts.find(
8628
+ (fact) => fact.kind === "pptx/canvas"
8629
+ );
8630
+ const safe = canvas?.safeAreaPt;
8631
+ if (canvas === void 0 || safe === void 0 || safe <= 0) return [];
8632
+ const chromePaths = new Set(
8633
+ facts.filter(
8634
+ (fact) => fact.kind === "pptx/chrome-slot" && (fact.role === "tracker" || fact.role === "footer")
8635
+ ).map((fact) => fact.path)
8636
+ );
8637
+ const chromeStyles = /* @__PURE__ */ new Set(["footer", "tracker"]);
8638
+ const styledChrome = new Set(
8639
+ textFacts(facts).filter(
8640
+ (fact) => fact.styleName !== void 0 && chromeStyles.has(fact.styleName)
8641
+ ).map((fact) => fact.path)
8642
+ );
8643
+ return facts.filter((fact) => fact.kind === "pptx/box").filter(
8644
+ (fact) => !chromePaths.has(fact.path) && !styledChrome.has(fact.path)
8645
+ ).flatMap((fact) => {
8646
+ const right = fact.xPt + fact.widthPt;
8647
+ const bottom = fact.yPt + fact.heightPt;
8648
+ const spansWidth = fact.xPt <= tolerance && right >= canvas.widthPt - tolerance;
8649
+ const spansHeight = fact.yPt <= tolerance && bottom >= canvas.heightPt - tolerance;
8650
+ if (spansWidth || spansHeight) return [];
8651
+ const breaches = [];
8652
+ if (fact.xPt < safe - tolerance) breaches.push("left");
8653
+ if (fact.yPt < safe - tolerance) breaches.push("top");
8654
+ if (right > canvas.widthPt - safe + tolerance) breaches.push("right");
8655
+ if (bottom > canvas.heightPt - safe + tolerance)
8656
+ breaches.push("bottom");
8657
+ if (breaches.length === 0) return [];
8658
+ const worst = Math.max(
8659
+ safe - fact.xPt,
8660
+ safe - fact.yPt,
8661
+ right - (canvas.widthPt - safe),
8662
+ bottom - (canvas.heightPt - safe)
8663
+ );
8664
+ return [
8665
+ {
8666
+ path: fact.path,
8667
+ message: `This ${fact.componentName} crosses the theme's ${Math.round(safe)}pt safe area at the ${breaches.join(" and ")}, by ${Math.round(worst * 10) / 10}pt.`,
8668
+ suggestion: "Move it inside the margin, or make it a full bleed that runs edge to edge.",
8669
+ context: { edges: breaches, safeAreaPt: safe },
8670
+ evidence: {
8671
+ actual: Math.round(worst * 10) / 10,
8672
+ expected: 0,
8673
+ unit: "pt",
8674
+ values: { source: "theme" }
8675
+ }
8676
+ }
8677
+ ];
8678
+ });
8679
+ }
8680
+ };
8681
+ var pptxSlideTitleRule = {
8682
+ id: "pptx/slide-title",
8683
+ description: "A content slide with no title: no actionTitle slot, no box in a title style. Off until a profile or policy enables it.",
8684
+ code: QUALITY_CODES.SLIDE_UNTITLED,
8685
+ category: "hierarchy",
8686
+ defaultSeverity: "warning",
8687
+ defaultCertainty: "deterministic",
8688
+ formats: ["pptx"],
8689
+ defaultEnabled: false,
8690
+ defaultParameters: { titleStyles: ["title", "display", "heading1"] },
8691
+ evaluate: ({ facts, configuration }) => {
8692
+ const styles = stringListParameter(configuration.parameters, "titleStyles");
8693
+ const titledSlides = /* @__PURE__ */ new Set();
8694
+ for (const fact of textFacts(facts))
8695
+ if (fact.styleName !== void 0 && styles.includes(fact.styleName))
8696
+ titledSlides.add(fact.slidePath);
8697
+ for (const fact of facts)
8698
+ if (fact.kind === "pptx/chrome-slot" && fact.role === "actionTitle" && fact.present)
8699
+ titledSlides.add(fact.slidePath);
8700
+ return facts.filter(
8701
+ (fact) => fact.kind === "pptx/slide" && fact.contentBoxes > 0 && !titledSlides.has(fact.path)
8702
+ ).map((fact) => ({
8703
+ path: fact.path,
8704
+ message: "This slide carries content under no title, so nothing tells the reader what it is for.",
8705
+ suggestion: "Lead the slide with a claim: an action title in a block, or a box in the theme\u2019s title style.",
8706
+ context: { contentBoxes: fact.contentBoxes },
8707
+ evidence: {
8708
+ actual: 0,
8709
+ expected: 1,
8710
+ unit: "titles",
8711
+ values: { source: "profile" }
8712
+ }
8713
+ }));
8714
+ }
8715
+ };
8716
+ var pptxImageAspectRule = {
8717
+ id: "pptx/image-aspect",
8718
+ description: "An image drawn at an aspect the asset does not have, where the asset can be read from the document.",
8719
+ code: QUALITY_CODES.IMAGE_ASPECT,
8720
+ category: "integrity",
8721
+ defaultSeverity: "warning",
8722
+ defaultCertainty: "deterministic",
8723
+ formats: ["pptx"],
8724
+ defaultParameters: { tolerance: DEFAULT_IMAGE_ASPECT_TOLERANCE },
8725
+ evaluate: ({ facts, configuration }) => {
8726
+ const tolerance = numberParameter(
8727
+ configuration.parameters,
8728
+ "tolerance",
8729
+ DEFAULT_IMAGE_ASPECT_TOLERANCE
8730
+ );
8731
+ return facts.filter(
8732
+ (fact) => fact.kind === "pptx/image" && fact.drawnRatio !== void 0 && fact.naturalRatio !== void 0
8733
+ ).flatMap((fact) => {
8734
+ const finding = imageAspectFinding(
8735
+ {
8736
+ path: fact.path,
8737
+ drawn: fact.drawnRatio,
8738
+ natural: fact.naturalRatio
8739
+ },
8740
+ "slide",
8741
+ tolerance
8742
+ );
8743
+ return finding ? [finding] : [];
8744
+ });
8745
+ }
8746
+ };
8257
8747
  var PPTX_QUALITY_RULES = {
8258
8748
  id: "pptx/default",
8259
8749
  rules: [
@@ -8271,7 +8761,15 @@ var PPTX_QUALITY_RULES = {
8271
8761
  pptxOffCanvasRule,
8272
8762
  pptxSlotBudgetRule,
8273
8763
  pptxRequiredChromeRule,
8274
- pptxActionTitleRule
8764
+ pptxActionTitleRule,
8765
+ pptxTypeScaleRule,
8766
+ pptxSizeCountRule,
8767
+ pptxRoleDriftRule,
8768
+ pptxTitleDriftRule,
8769
+ pptxBulletRule,
8770
+ pptxSafeAreaRule,
8771
+ pptxSlideTitleRule,
8772
+ pptxImageAspectRule
8275
8773
  ]
8276
8774
  };
8277
8775
  var PPTX_QUALITY_PROFILES = {
@@ -8281,7 +8779,11 @@ var PPTX_QUALITY_PROFILES = {
8281
8779
  description: "Decision deck optimized for scan speed and projection.",
8282
8780
  rules: {
8283
8781
  "pptx/minimum-font-size": { parameters: { minimumFontPt: 14 } },
8284
- "pptx/slide-density": { parameters: { maximumBodyWords: 70 } }
8782
+ "pptx/slide-density": { parameters: { maximumBodyWords: 70 } },
8783
+ "pptx/bullet-density": {
8784
+ parameters: { maximumBullets: 5, maximumWordsPerBullet: 12 }
8785
+ },
8786
+ "pptx/slide-title": { enabled: true }
8285
8787
  }
8286
8788
  },
8287
8789
  "technical-presentation": {
@@ -8292,13 +8794,25 @@ var PPTX_QUALITY_PROFILES = {
8292
8794
  "consulting-deck": {
8293
8795
  id: "consulting-deck",
8294
8796
  formats: ["pptx"],
8295
- description: "Consulting readout: every content slide leads with a two-line action title, every chart carries a takeaway and a source.",
8797
+ 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.",
8296
8798
  rules: {
8297
8799
  "pptx/required-chrome": {
8298
8800
  parameters: { required: ["takeaway", "source"] }
8299
8801
  },
8300
8802
  "pptx/action-title": { parameters: { maxLines: 2 } },
8301
- "pptx/slide-density": { parameters: { maximumBodyWords: 90 } }
8803
+ "pptx/slide-density": { parameters: { maximumBodyWords: 90 } },
8804
+ "pptx/type-scale": { enabled: true },
8805
+ "pptx/size-count": { enabled: true, parameters: { maximumSizes: 8 } },
8806
+ "pptx/role-drift": { enabled: true },
8807
+ "pptx/title-drift": {
8808
+ enabled: true,
8809
+ parameters: { titleStyles: ["title", "display"] }
8810
+ },
8811
+ "pptx/bullet-density": {
8812
+ parameters: { maximumBullets: 5, maximumWordsPerBullet: 12 }
8813
+ },
8814
+ "pptx/safe-area": { enabled: true },
8815
+ "pptx/slide-title": { enabled: true }
8302
8816
  }
8303
8817
  }
8304
8818
  };