@json-to-office/core-docx 6.0.0 → 6.2.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
@@ -5605,7 +5605,7 @@ async function rasterizeVisualSlide(presentation, dpi, propsServerUrl, serviceCo
5605
5605
  serviceConfig?.serverUrl,
5606
5606
  DEFAULT_RASTERIZE_SERVER_URL
5607
5607
  );
5608
- const response = await postJsonToService({
5608
+ const body = await postJsonToService({
5609
5609
  url: serverUrl,
5610
5610
  path: "/rasterize",
5611
5611
  body: {
@@ -5617,11 +5617,12 @@ async function rasterizeVisualSlide(presentation, dpi, propsServerUrl, serviceCo
5617
5617
  headers: serviceConfig?.headers,
5618
5618
  serviceLabel: "PPTX rasterization service",
5619
5619
  onUnreachable: (url, cause) => `PPTX rasterization service is not reachable at ${url}. Configure services.pptx with a \`render\` callback or a running \`serverUrl\`.
5620
- Cause: ${cause}`
5620
+ Cause: ${cause}`,
5621
+ decode: (response) => response.text()
5621
5622
  });
5622
5623
  let result;
5623
5624
  try {
5624
- result = await response.json();
5625
+ result = JSON.parse(body);
5625
5626
  } catch {
5626
5627
  throw new Error(
5627
5628
  "PPTX rasterization service returned a non-JSON response (expected { base64DataUri, width, height })."
@@ -5817,9 +5818,9 @@ async function prerasterizeVisuals(root, serviceConfig, options = {}) {
5817
5818
  DEFAULT_RASTERIZE_SERVER_URL
5818
5819
  );
5819
5820
  const postBatch = async (chunk, fonts) => {
5820
- let response;
5821
+ let body;
5821
5822
  try {
5822
- response = await postJsonToService2({
5823
+ body = await postJsonToService2({
5823
5824
  url: serverUrl,
5824
5825
  path: "/rasterize/batch",
5825
5826
  body: {
@@ -5832,14 +5833,21 @@ async function prerasterizeVisuals(root, serviceConfig, options = {}) {
5832
5833
  },
5833
5834
  headers: serviceConfig?.headers,
5834
5835
  timeoutMs: BATCH_TIMEOUT_BASE_MS + BATCH_TIMEOUT_PER_SLIDE_MS * chunk.length,
5836
+ // The per-visual fallback below IS this call's retry, and a better
5837
+ // one: it degrades to smaller work instead of repeating the same
5838
+ // large request. Retrying here first would spend three full batch
5839
+ // timeouts — up to seventeen minutes on a 32-slide chunk — before
5840
+ // the fallback that was going to run anyway gets its turn.
5841
+ retries: 0,
5835
5842
  serviceLabel: "PPTX batch rasterization service",
5836
- onUnreachable: (url, cause) => `PPTX rasterization service is not reachable at ${url}. Cause: ${cause}`
5843
+ onUnreachable: (url, cause) => `PPTX rasterization service is not reachable at ${url}. Cause: ${cause}`,
5844
+ decode: (response) => response.text()
5837
5845
  });
5838
5846
  } catch (error) {
5839
5847
  return { applied: false, schemaRejected: isSchemaRejection(error) };
5840
5848
  }
5841
5849
  try {
5842
- if (applyBatchResponse(chunk, await response.json(), map)) {
5850
+ if (applyBatchResponse(chunk, JSON.parse(body), map)) {
5843
5851
  return { applied: true };
5844
5852
  }
5845
5853
  } catch {
@@ -5946,7 +5954,7 @@ function containsComponent(root, name) {
5946
5954
  import {
5947
5955
  clampVisualDpi as clampVisualDpi2,
5948
5956
  DEFAULT_VISUAL_DPI as DEFAULT_VISUAL_DPI2,
5949
- limitChartRequest
5957
+ recordChartCollected
5950
5958
  } from "@json-to-office/shared";
5951
5959
  import {
5952
5960
  isNativeVisualProps as isNativeVisualProps2
@@ -5965,9 +5973,8 @@ import {
5965
5973
  withChartTypography,
5966
5974
  resolveServiceUrl as resolveServiceUrl3,
5967
5975
  postJsonToService as postJsonToService3,
5968
- chartRequestKey,
5969
- dedupeChartRequest,
5970
- recordChartRetry
5976
+ recordChartRetry,
5977
+ sendChartRequest
5971
5978
  } from "@json-to-office/shared";
5972
5979
  var DEFAULT_EXPORT_SERVER_URL = "http://localhost:7801";
5973
5980
  function effectiveChartServerUrl(props, servicesConfig) {
@@ -6008,14 +6015,16 @@ async function generateChart(config, servicesConfig, warnings, cache) {
6008
6015
  // byte-identical to before for callers that omit it.
6009
6016
  ...config.resources ? { resources: config.resources } : {}
6010
6017
  };
6011
- return dedupeChartRequest(
6018
+ return sendChartRequest({
6012
6019
  cache,
6013
- chartRequestKey(serverUrl, requestBody),
6014
- () => postChart(serverUrl, requestBody, config, servicesConfig)
6015
- );
6020
+ serverUrl,
6021
+ concurrency: servicesConfig?.concurrency,
6022
+ requestBody,
6023
+ send: () => postChart(serverUrl, requestBody, config, servicesConfig)
6024
+ });
6016
6025
  }
6017
6026
  async function postChart(serverUrl, requestBody, config, servicesConfig) {
6018
- const response = await postJsonToService3({
6027
+ const base64Data = await postJsonToService3({
6019
6028
  url: serverUrl,
6020
6029
  path: "/export",
6021
6030
  body: requestBody,
@@ -6025,16 +6034,13 @@ async function postChart(serverUrl, requestBody, config, servicesConfig) {
6025
6034
  onRetry: recordChartRetry,
6026
6035
  serviceLabel: "Highcharts export server",
6027
6036
  onUnreachable: (url, cause) => `Highcharts Export Server is not running at ${url}. Start it with: npx highcharts-export-server --enableServer true
6028
- Cause: ${cause}`
6037
+ Cause: ${cause}`,
6038
+ decode: (response) => response.text()
6029
6039
  });
6030
- const base64Data = await response.text();
6031
- const base64DataUri = `data:image/png;base64,${base64Data}`;
6032
- const width = config.options.chart.width;
6033
- const height = config.options.chart.height;
6034
6040
  return {
6035
- base64DataUri,
6036
- width,
6037
- height
6041
+ base64DataUri: `data:image/png;base64,${base64Data}`,
6042
+ width: config.options.chart.width,
6043
+ height: config.options.chart.height
6038
6044
  };
6039
6045
  }
6040
6046
  function toChartColor(value, theme) {
@@ -6162,21 +6168,16 @@ async function desugarExternals(document, options) {
6162
6168
  });
6163
6169
  }
6164
6170
  if (node.name === "highcharts") {
6165
- const props = node.props;
6166
- const chartConfig = options.services?.highcharts;
6171
+ recordChartCollected();
6167
6172
  return withNodeIdentity(node, {
6168
6173
  name: "image",
6169
- props: await limitChartRequest(
6170
- effectiveChartServerUrl(props, chartConfig),
6171
- chartConfig?.concurrency,
6172
- () => renderChartToImageProps(
6173
- props,
6174
- options.theme,
6175
- chartConfig,
6176
- options.chartFonts,
6177
- options.warnings,
6178
- chartCache
6179
- )
6174
+ props: await renderChartToImageProps(
6175
+ node.props,
6176
+ options.theme,
6177
+ options.services?.highcharts,
6178
+ options.chartFonts,
6179
+ options.warnings,
6180
+ chartCache
6180
6181
  )
6181
6182
  });
6182
6183
  }
@@ -12883,6 +12884,7 @@ async function expandBlocksWithPlugins(document, theme, plugins, render, preserv
12883
12884
  init_styleHelpers();
12884
12885
  init_defaults();
12885
12886
  init_widthUtils();
12887
+ import probe2 from "probe-image-size";
12886
12888
 
12887
12889
  // src/quality/text-inventory.ts
12888
12890
  function tocDepth(depth) {
@@ -13133,6 +13135,116 @@ function collectDocxTextInventory(children, basePath = "/children") {
13133
13135
  return entries;
13134
13136
  }
13135
13137
 
13138
+ // src/quality/text-metrics.ts
13139
+ var DEFAULT_ADVANCE = 556;
13140
+ var ADVANCE = {
13141
+ " ": 278,
13142
+ A: 667,
13143
+ B: 667,
13144
+ C: 722,
13145
+ D: 722,
13146
+ E: 667,
13147
+ F: 611,
13148
+ G: 778,
13149
+ H: 722,
13150
+ I: 278,
13151
+ J: 500,
13152
+ K: 667,
13153
+ L: 556,
13154
+ M: 833,
13155
+ N: 722,
13156
+ O: 778,
13157
+ P: 667,
13158
+ Q: 778,
13159
+ R: 722,
13160
+ S: 667,
13161
+ T: 611,
13162
+ U: 722,
13163
+ V: 667,
13164
+ W: 944,
13165
+ X: 667,
13166
+ Y: 667,
13167
+ Z: 611,
13168
+ a: 556,
13169
+ b: 556,
13170
+ c: 500,
13171
+ d: 556,
13172
+ e: 556,
13173
+ f: 278,
13174
+ g: 556,
13175
+ h: 556,
13176
+ i: 222,
13177
+ j: 222,
13178
+ k: 500,
13179
+ l: 222,
13180
+ m: 833,
13181
+ n: 556,
13182
+ o: 556,
13183
+ p: 556,
13184
+ q: 556,
13185
+ r: 333,
13186
+ s: 500,
13187
+ t: 278,
13188
+ u: 556,
13189
+ v: 500,
13190
+ w: 722,
13191
+ x: 500,
13192
+ y: 500,
13193
+ z: 500,
13194
+ ".": 278,
13195
+ ",": 278,
13196
+ ":": 278,
13197
+ ";": 278,
13198
+ "!": 278,
13199
+ "?": 556,
13200
+ "'": 191,
13201
+ '"': 355,
13202
+ "-": 333,
13203
+ "\u2013": 556,
13204
+ "\u2014": 1e3,
13205
+ "&": 667,
13206
+ "%": 889,
13207
+ "(": 333,
13208
+ ")": 333,
13209
+ "/": 278,
13210
+ "@": 1015,
13211
+ "#": 556,
13212
+ "+": 584,
13213
+ "=": 584
13214
+ };
13215
+ function estimateTextWidthPt(text, fontSizePt, trackingPt = 0) {
13216
+ let units = 0;
13217
+ for (const character of text) {
13218
+ units += ADVANCE[character] ?? DEFAULT_ADVANCE;
13219
+ }
13220
+ const width = units / 1e3 * fontSizePt + text.length * trackingPt;
13221
+ return Math.max(0, width);
13222
+ }
13223
+ function estimateWrappedLines(text, widthPt, fontSizePt, trackingPt = 0) {
13224
+ if (widthPt <= 0) return 1;
13225
+ let lines = 0;
13226
+ for (const paragraph2 of text.split("\n")) {
13227
+ const words = paragraph2.split(/\s+/).filter(Boolean);
13228
+ if (words.length === 0) {
13229
+ lines += 1;
13230
+ continue;
13231
+ }
13232
+ let current = "";
13233
+ let used = 0;
13234
+ for (const word of words) {
13235
+ const candidate = current === "" ? word : `${current} ${word}`;
13236
+ if (estimateTextWidthPt(candidate, fontSizePt, trackingPt) <= widthPt) {
13237
+ current = candidate;
13238
+ } else {
13239
+ if (current !== "") used += 1;
13240
+ current = word;
13241
+ }
13242
+ }
13243
+ lines += used + (current === "" ? 0 : 1);
13244
+ }
13245
+ return Math.max(1, lines);
13246
+ }
13247
+
13136
13248
  // src/quality/facts.ts
13137
13249
  function asRecord2(value) {
13138
13250
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
@@ -13944,14 +14056,29 @@ function prepareDocxQualityDocument(document, options = {}) {
13944
14056
  }
13945
14057
  if (node.name === "image" || node.name === "visual") {
13946
14058
  for (const fact of svgTextFacts(props, path4)) addFact(fact);
14059
+ const drawn = drawnRatio(props);
14060
+ const natural = readableRatio(props);
14061
+ addFact({
14062
+ id: `docx:image:${path4}`,
14063
+ kind: "docx/image",
14064
+ path: path4,
14065
+ ...typeof props.alt === "string" && props.alt.trim() !== "" && { alt: props.alt },
14066
+ captioned: isCaptioned(facts, authoredPath(path4)),
14067
+ ...drawn !== void 0 && { drawnRatio: drawn },
14068
+ ...natural !== void 0 && { naturalRatio: natural }
14069
+ });
13947
14070
  }
13948
14071
  if (node.name === "heading") {
13949
14072
  const level = typeof props.level === "number" && Number.isFinite(props.level) ? props.level : 1;
14073
+ const styleKeepNext = asRecord2(
14074
+ typography.styles[`heading${level}`]
14075
+ )?.keepNext;
13950
14076
  addFact({
13951
14077
  id: `docx:heading:${path4}`,
13952
14078
  kind: "docx/heading",
13953
14079
  path: `${path4}/props/level`,
13954
14080
  level,
14081
+ keepNext: props.keepNext === true || props.keepNext === void 0 && styleKeepNext === true,
13955
14082
  ...previousHeadingLevel !== void 0 && {
13956
14083
  previousLevel: previousHeadingLevel
13957
14084
  }
@@ -13966,6 +14093,65 @@ function prepareDocxQualityDocument(document, options = {}) {
13966
14093
  const page = rec.name === "section" ? pageBox(resolved.theme, context.themeName, rec.props?.page) : basePage;
13967
14094
  walkActive(component, `/children/${index}`, page, visit);
13968
14095
  });
14096
+ const wordsOf = (text) => text.split(/\s+/).filter(Boolean).length;
14097
+ const bodyRoles = /* @__PURE__ */ new Set([
14098
+ "body",
14099
+ "list-item",
14100
+ "table-header",
14101
+ "table-cell",
14102
+ "statistic",
14103
+ "caption"
14104
+ ]);
14105
+ const exhibitKinds = /* @__PURE__ */ new Set(["docx/table", "docx/chart", "docx/image"]);
14106
+ resolved.children.forEach((component, index) => {
14107
+ const rec = component;
14108
+ if (rec.name !== "section") return;
14109
+ const prefix = `/children/${index}/`;
14110
+ const own = facts.filter((fact) => fact.path.startsWith(prefix));
14111
+ const texts = own.filter(
14112
+ (fact) => fact.kind === "docx/text"
14113
+ );
14114
+ addFact({
14115
+ id: `docx:section:${index}`,
14116
+ kind: "docx/section",
14117
+ path: `/children/${index}`,
14118
+ index,
14119
+ words: texts.filter((fact) => bodyRoles.has(fact.role)).reduce((total, fact) => total + wordsOf(fact.text), 0),
14120
+ exhibits: own.filter((fact) => exhibitKinds.has(fact.kind)).length,
14121
+ headings: texts.filter((fact) => fact.role === "heading").length
14122
+ });
14123
+ if (hasColumnsComponent(rec)) return;
14124
+ const page = pageBox(
14125
+ resolved.theme,
14126
+ context.themeName,
14127
+ rec.props?.page
14128
+ );
14129
+ const fontSizePt = effectiveFontSize({ name: "paragraph" }, {}, typography)?.fontSizePt ?? 11;
14130
+ const widthPt = page.availableWidthTwips / 20;
14131
+ const averageAdvancePt = estimateTextWidthPt(MEASURE_SAMPLE, fontSizePt) / MEASURE_SAMPLE.length;
14132
+ if (averageAdvancePt > 0)
14133
+ addFact({
14134
+ id: `docx:measure:${index}`,
14135
+ kind: "docx/measure",
14136
+ path: `/children/${index}`,
14137
+ index,
14138
+ charactersPerLine: Math.round(widthPt / averageAdvancePt),
14139
+ widthTwips: page.availableWidthTwips,
14140
+ fontSizePt
14141
+ });
14142
+ });
14143
+ const headingCount = facts.filter(
14144
+ (fact) => fact.kind === "docx/text" && fact.role === "heading"
14145
+ ).length;
14146
+ addFact({
14147
+ id: "docx:outline",
14148
+ kind: "docx/outline",
14149
+ path: "/props",
14150
+ headings: headingCount,
14151
+ contents: facts.some(
14152
+ (fact) => fact.kind === "docx/text" && fact.role === "toc-entry"
14153
+ )
14154
+ });
13969
14155
  return {
13970
14156
  format: "docx",
13971
14157
  model: {
@@ -13996,6 +14182,52 @@ function prepareDocxQualityDocument(document, options = {}) {
13996
14182
  }
13997
14183
  };
13998
14184
  }
14185
+ function hasColumnsComponent(node) {
14186
+ if (Array.isArray(node)) return node.some(hasColumnsComponent);
14187
+ const rec = asRecord2(node);
14188
+ if (!rec) return false;
14189
+ if (rec.name === "columns") return true;
14190
+ return Object.values(rec).some(hasColumnsComponent);
14191
+ }
14192
+ var CAPTION_LABEL = /^\s*(?:\*\*)?\s*(figure|exhibit|table|chart|image)\b/i;
14193
+ function isCaptioned(facts, image) {
14194
+ return facts.some((fact) => {
14195
+ if (fact.kind !== "docx/text") return false;
14196
+ const text = fact;
14197
+ const near = siblingOf(text.path) === siblingOf(image) || text.path.startsWith(`${image}/`) || image.startsWith(`${text.path}/`);
14198
+ return near && (text.role === "caption" || CAPTION_LABEL.test(text.text));
14199
+ });
14200
+ }
14201
+ function siblingOf(pointer) {
14202
+ return pointer.slice(0, pointer.lastIndexOf("/"));
14203
+ }
14204
+ var MEASURE_SAMPLE = "the quick brown fox jumps over the lazy dog and then settles under it ";
14205
+ function drawnRatio(props) {
14206
+ const width = finiteNumber(props.width);
14207
+ const height = finiteNumber(props.height);
14208
+ return width !== void 0 && height !== void 0 && height > 0 ? width / height : void 0;
14209
+ }
14210
+ function readableRatio(props) {
14211
+ if (typeof props.svg === "string") {
14212
+ const box = /viewBox\s*=\s*["']\s*[-\d.]+[ ,]+[-\d.]+[ ,]+([\d.]+)[ ,]+([\d.]+)/.exec(
14213
+ props.svg
14214
+ );
14215
+ const width = box ? Number(box[1]) : NaN;
14216
+ const height = box ? Number(box[2]) : NaN;
14217
+ return Number.isFinite(width) && Number.isFinite(height) && height > 0 ? width / height : void 0;
14218
+ }
14219
+ if (typeof props.base64 !== "string") return void 0;
14220
+ const comma = props.base64.indexOf(",");
14221
+ if (comma < 0) return void 0;
14222
+ try {
14223
+ const size = probe2.sync(
14224
+ Buffer.from(props.base64.slice(comma + 1), "base64")
14225
+ );
14226
+ return size && size.height > 0 ? size.width / size.height : void 0;
14227
+ } catch {
14228
+ return void 0;
14229
+ }
14230
+ }
13999
14231
  function slotIsFilled(value) {
14000
14232
  if (typeof value === "string") return value.trim() !== "";
14001
14233
  return value !== void 0 && value !== null && value !== false && (!Array.isArray(value) || value.length > 0);
@@ -14397,122 +14629,16 @@ import {
14397
14629
  nearestPaletteToken,
14398
14630
  offPaletteFinding,
14399
14631
  placeholderFinding,
14632
+ DEFAULT_IMAGE_ASPECT_TOLERANCE,
14633
+ driftingSizes,
14634
+ imageAspectFinding,
14635
+ offScaleFindings,
14400
14636
  QUALITY_CODES,
14637
+ roleDriftFindings,
14638
+ sizeCountFinding,
14401
14639
  QualityEngine,
14402
14640
  tableInfoDesignFindings
14403
14641
  } from "@json-to-office/quality";
14404
-
14405
- // src/quality/text-metrics.ts
14406
- var DEFAULT_ADVANCE = 556;
14407
- var ADVANCE = {
14408
- " ": 278,
14409
- A: 667,
14410
- B: 667,
14411
- C: 722,
14412
- D: 722,
14413
- E: 667,
14414
- F: 611,
14415
- G: 778,
14416
- H: 722,
14417
- I: 278,
14418
- J: 500,
14419
- K: 667,
14420
- L: 556,
14421
- M: 833,
14422
- N: 722,
14423
- O: 778,
14424
- P: 667,
14425
- Q: 778,
14426
- R: 722,
14427
- S: 667,
14428
- T: 611,
14429
- U: 722,
14430
- V: 667,
14431
- W: 944,
14432
- X: 667,
14433
- Y: 667,
14434
- Z: 611,
14435
- a: 556,
14436
- b: 556,
14437
- c: 500,
14438
- d: 556,
14439
- e: 556,
14440
- f: 278,
14441
- g: 556,
14442
- h: 556,
14443
- i: 222,
14444
- j: 222,
14445
- k: 500,
14446
- l: 222,
14447
- m: 833,
14448
- n: 556,
14449
- o: 556,
14450
- p: 556,
14451
- q: 556,
14452
- r: 333,
14453
- s: 500,
14454
- t: 278,
14455
- u: 556,
14456
- v: 500,
14457
- w: 722,
14458
- x: 500,
14459
- y: 500,
14460
- z: 500,
14461
- ".": 278,
14462
- ",": 278,
14463
- ":": 278,
14464
- ";": 278,
14465
- "!": 278,
14466
- "?": 556,
14467
- "'": 191,
14468
- '"': 355,
14469
- "-": 333,
14470
- "\u2013": 556,
14471
- "\u2014": 1e3,
14472
- "&": 667,
14473
- "%": 889,
14474
- "(": 333,
14475
- ")": 333,
14476
- "/": 278,
14477
- "@": 1015,
14478
- "#": 556,
14479
- "+": 584,
14480
- "=": 584
14481
- };
14482
- function estimateTextWidthPt(text, fontSizePt, trackingPt = 0) {
14483
- let units = 0;
14484
- for (const character of text) {
14485
- units += ADVANCE[character] ?? DEFAULT_ADVANCE;
14486
- }
14487
- const width = units / 1e3 * fontSizePt + text.length * trackingPt;
14488
- return Math.max(0, width);
14489
- }
14490
- function estimateWrappedLines(text, widthPt, fontSizePt, trackingPt = 0) {
14491
- if (widthPt <= 0) return 1;
14492
- let lines = 0;
14493
- for (const paragraph2 of text.split("\n")) {
14494
- const words = paragraph2.split(/\s+/).filter(Boolean);
14495
- if (words.length === 0) {
14496
- lines += 1;
14497
- continue;
14498
- }
14499
- let current = "";
14500
- let used = 0;
14501
- for (const word of words) {
14502
- const candidate = current === "" ? word : `${current} ${word}`;
14503
- if (estimateTextWidthPt(candidate, fontSizePt, trackingPt) <= widthPt) {
14504
- current = candidate;
14505
- } else {
14506
- if (current !== "") used += 1;
14507
- current = word;
14508
- }
14509
- }
14510
- lines += used + (current === "" ? 0 : 1);
14511
- }
14512
- return Math.max(1, lines);
14513
- }
14514
-
14515
- // src/quality/rules.ts
14516
14642
  var WIDTH_TOLERANCE_TWIPS = 10;
14517
14643
  function numberParameter(parameters, name, fallback) {
14518
14644
  const value = parameters[name];
@@ -15169,44 +15295,23 @@ var docxRunningHeadRule = {
15169
15295
  });
15170
15296
  }
15171
15297
  };
15172
- var SIZE_TOLERANCE_PT = 0.25;
15173
15298
  function themeFact(facts) {
15174
15299
  return facts.find(
15175
15300
  (fact) => fact.kind === "docx/theme"
15176
15301
  );
15177
15302
  }
15178
- function textSizeFacts(facts) {
15179
- return facts.filter(
15180
- (fact) => fact.kind === "docx/text-size"
15181
- );
15182
- }
15183
- function roleDriftFacts(facts, theme) {
15184
- const byRole = /* @__PURE__ */ new Map();
15185
- for (const fact of textSizeFacts(facts)) {
15186
- byRole.set(fact.role, [...byRole.get(fact.role) ?? [], fact]);
15187
- }
15188
- const drifting = /* @__PURE__ */ new Map();
15189
- for (const [role, members] of byRole) {
15190
- const sizes = [...new Set(members.map((fact) => fact.fontSizePt))].sort(
15191
- (a, b) => a - b
15192
- );
15193
- if (sizes.length < 2) continue;
15194
- const expected = theme?.roleSizesPt[role];
15195
- if (expected === void 0) continue;
15196
- for (const fact of members) {
15197
- if (fact.sizePath !== void 0 && !fact.generated && Math.abs(fact.fontSizePt - expected) > SIZE_TOLERANCE_PT)
15198
- drifting.set(fact, { expected, sizes });
15199
- }
15200
- }
15201
- return drifting;
15202
- }
15203
- function nearestSize(size, scale) {
15204
- let best;
15205
- for (const candidate of scale) {
15206
- if (best === void 0 || Math.abs(candidate - size) < Math.abs(best - size))
15207
- best = candidate;
15208
- }
15209
- return best;
15303
+ var DOCX_TYPE_VOCABULARY = {
15304
+ subject: "document",
15305
+ keepTo: "Keep to the theme styles \u2014 title, headings, body, label, source \u2014 and drop the ad-hoc sizes."
15306
+ };
15307
+ function paintedSizes(facts) {
15308
+ return facts.filter((fact) => fact.kind === "docx/text-size").map((fact) => ({
15309
+ path: fact.path,
15310
+ role: fact.role,
15311
+ fontSizePt: fact.fontSizePt,
15312
+ ...fact.sizePath !== void 0 && { sizePath: fact.sizePath },
15313
+ generated: fact.generated
15314
+ }));
15210
15315
  }
15211
15316
  var docxTypeScaleRule = {
15212
15317
  id: "docx/type-scale",
@@ -15219,43 +15324,15 @@ var docxTypeScaleRule = {
15219
15324
  defaultEnabled: false,
15220
15325
  evaluate: ({ facts }) => {
15221
15326
  const theme = themeFact(facts);
15222
- const scale = theme?.typeScalePt ?? [];
15223
- if (scale.length === 0) return [];
15224
- const drifting = roleDriftFacts(facts, theme);
15225
- const offScale = textSizeFacts(facts).filter(
15226
- (fact) => fact.sizePath !== void 0 && !fact.generated && !drifting.has(fact) && !scale.some(
15227
- (size) => Math.abs(size - fact.fontSizePt) <= SIZE_TOLERANCE_PT
15228
- )
15327
+ if (!theme) return [];
15328
+ const sizes = paintedSizes(facts);
15329
+ return offScaleFindings(
15330
+ sizes,
15331
+ theme.typeScalePt,
15332
+ theme.themeName,
15333
+ DOCX_TYPE_VOCABULARY,
15334
+ driftingSizes(sizes, theme.roleSizesPt)
15229
15335
  );
15230
- const groups = /* @__PURE__ */ new Map();
15231
- for (const fact of offScale) {
15232
- const key = `${fact.role}@${fact.fontSizePt}`;
15233
- groups.set(key, [...groups.get(key) ?? [], fact]);
15234
- }
15235
- return [...groups.values()].map((members) => {
15236
- const [first] = members;
15237
- const nearest = nearestSize(first.fontSizePt, scale);
15238
- const sizePaths = members.map((fact) => fact.sizePath);
15239
- const count = members.length === 1 ? "" : ` (${members.length} places, patched together)`;
15240
- return {
15241
- path: sizePaths[0],
15242
- ...sizePaths.length > 1 && { relatedPaths: sizePaths.slice(1) },
15243
- message: `${first.fontSizePt}pt is not a size the ${theme.themeName} theme paints; the nearest on its scale is ${nearest}pt${count}.`,
15244
- suggestion: `Use ${nearest}pt, or drop the size and let the "${first.role}" style set it.`,
15245
- context: { role: first.role, scale, paths: sizePaths },
15246
- evidence: {
15247
- actual: first.fontSizePt,
15248
- expected: nearest,
15249
- unit: "pt",
15250
- values: { source: "theme" }
15251
- },
15252
- fixes: sizePaths.map((path4) => ({
15253
- op: "replace",
15254
- path: path4,
15255
- value: nearest
15256
- }))
15257
- };
15258
- });
15259
15336
  }
15260
15337
  };
15261
15338
  var docxSizeCountRule = {
@@ -15268,34 +15345,13 @@ var docxSizeCountRule = {
15268
15345
  formats: ["docx"],
15269
15346
  defaultEnabled: false,
15270
15347
  defaultParameters: { maximumSizes: 8 },
15271
- evaluate: ({ facts, configuration, profile }) => {
15272
- const maximum = numberParameter(
15273
- configuration.parameters,
15274
- "maximumSizes",
15275
- 8
15276
- );
15277
- const firstPathBySize = /* @__PURE__ */ new Map();
15278
- for (const fact of textSizeFacts(facts)) {
15279
- const size = Math.round(fact.fontSizePt * 4) / 4;
15280
- if (!firstPathBySize.has(size)) firstPathBySize.set(size, fact.path);
15281
- }
15282
- if (firstPathBySize.size <= maximum) return [];
15283
- const sizes = [...firstPathBySize.keys()].sort((a, b) => a - b);
15284
- return [
15285
- {
15286
- path: themeFact(facts)?.path ?? "/props",
15287
- relatedPaths: sizes.map((size) => firstPathBySize.get(size)),
15288
- message: `The document paints ${sizes.length} distinct text sizes (${sizes.join(", ")}pt); the ${profile?.id ?? "selected"} profile allows ${maximum}.`,
15289
- suggestion: "Keep to the theme styles \u2014 title, headings, body, label, source \u2014 and drop the ad-hoc sizes.",
15290
- context: { sizes, maximum },
15291
- evidence: {
15292
- actual: sizes.length,
15293
- expected: maximum,
15294
- values: { source: "profile" }
15295
- }
15296
- }
15297
- ];
15298
- }
15348
+ evaluate: ({ facts, configuration, profile }) => sizeCountFinding(
15349
+ paintedSizes(facts),
15350
+ numberParameter(configuration.parameters, "maximumSizes", 8),
15351
+ themeFact(facts)?.path ?? "/props",
15352
+ profile?.id,
15353
+ DOCX_TYPE_VOCABULARY
15354
+ )
15299
15355
  };
15300
15356
  var docxRoleDriftRule = {
15301
15357
  id: "docx/role-drift",
@@ -15308,29 +15364,220 @@ var docxRoleDriftRule = {
15308
15364
  defaultEnabled: false,
15309
15365
  evaluate: ({ facts }) => {
15310
15366
  const theme = themeFact(facts);
15311
- const findings = [];
15312
- for (const [fact, { expected, sizes }] of roleDriftFacts(facts, theme)) {
15313
- const keeper = textSizeFacts(facts).find(
15314
- (member) => member.role === fact.role && Math.abs(member.fontSizePt - expected) <= SIZE_TOLERANCE_PT
15315
- );
15316
- const others = sizes.filter((size) => size !== fact.fontSizePt);
15317
- const sizePath = fact.sizePath;
15318
- findings.push({
15319
- path: sizePath,
15320
- ...keeper && { relatedPaths: [keeper.path] },
15321
- message: `"${fact.role}" is painted at ${fact.fontSizePt}pt here and at ${others.join("pt, ")}pt elsewhere; the theme sets it at ${expected}pt.`,
15322
- suggestion: `Drop the size so "${fact.role}" paints at the theme's ${expected}pt.`,
15323
- context: { role: fact.role, sizes },
15367
+ return theme ? roleDriftFindings(paintedSizes(facts), theme.roleSizesPt) : [];
15368
+ }
15369
+ };
15370
+ var docxBodyMeasureRule = {
15371
+ id: "docx/body-measure",
15372
+ description: "A section whose body copy runs outside the readable measure. Off until a profile or policy enables it.",
15373
+ code: QUALITY_CODES.BODY_MEASURE,
15374
+ category: "legibility",
15375
+ defaultSeverity: "warning",
15376
+ defaultCertainty: "estimated",
15377
+ formats: ["docx"],
15378
+ defaultEnabled: false,
15379
+ defaultParameters: { minimumCharacters: 45, maximumCharacters: 90 },
15380
+ evaluate: ({ facts, configuration }) => {
15381
+ const minimum = numberParameter(
15382
+ configuration.parameters,
15383
+ "minimumCharacters",
15384
+ 45
15385
+ );
15386
+ const maximum = numberParameter(
15387
+ configuration.parameters,
15388
+ "maximumCharacters",
15389
+ 90
15390
+ );
15391
+ const words = new Map(
15392
+ facts.filter((fact) => fact.kind === "docx/section").map((fact) => [fact.index, fact.words])
15393
+ );
15394
+ return facts.filter((fact) => fact.kind === "docx/measure").filter((fact) => (words.get(fact.index) ?? 0) >= 40).filter(
15395
+ (fact) => fact.charactersPerLine < minimum || fact.charactersPerLine > maximum
15396
+ ).map((fact) => {
15397
+ const wide = fact.charactersPerLine > maximum;
15398
+ return {
15399
+ path: fact.path,
15400
+ message: `Body copy runs about ${fact.charactersPerLine} characters a line at ${fact.fontSizePt}pt; a readable measure is ${minimum}\u2013${maximum}.`,
15401
+ suggestion: wide ? "Widen the margins, set the body a size larger, or put the text in columns." : "Narrow the margins or set the body a size smaller.",
15402
+ context: {
15403
+ charactersPerLine: fact.charactersPerLine,
15404
+ minimum,
15405
+ maximum
15406
+ },
15324
15407
  evidence: {
15325
- actual: fact.fontSizePt,
15326
- expected,
15327
- unit: "pt",
15328
- values: { role: fact.role, source: "theme" }
15408
+ actual: fact.charactersPerLine,
15409
+ expected: wide ? maximum : minimum,
15410
+ unit: "characters",
15411
+ values: { source: "profile" }
15412
+ }
15413
+ };
15414
+ });
15415
+ }
15416
+ };
15417
+ var docxSectionContentRule = {
15418
+ id: "docx/section-content",
15419
+ description: "A section that renders nothing, or one that carries body copy under no heading. Off until a profile or policy enables it.",
15420
+ code: QUALITY_CODES.SECTION_EMPTY,
15421
+ category: "hierarchy",
15422
+ defaultSeverity: "warning",
15423
+ defaultCertainty: "deterministic",
15424
+ formats: ["docx"],
15425
+ defaultEnabled: false,
15426
+ defaultParameters: { minimumWordsForHeading: 60 },
15427
+ evaluate: ({ facts, configuration }) => {
15428
+ const minimumWords = numberParameter(
15429
+ configuration.parameters,
15430
+ "minimumWordsForHeading",
15431
+ 60
15432
+ );
15433
+ const sections = facts.filter(
15434
+ (fact) => fact.kind === "docx/section"
15435
+ );
15436
+ return sections.flatMap((fact) => {
15437
+ if (fact.words === 0 && fact.exhibits === 0)
15438
+ return [
15439
+ {
15440
+ path: fact.path,
15441
+ code: QUALITY_CODES.SECTION_EMPTY,
15442
+ message: "This section renders nothing: no text, no table, no figure. It still starts a page.",
15443
+ suggestion: "Fill the section, or remove it so the document does not open a page on nothing.",
15444
+ context: { index: fact.index }
15445
+ }
15446
+ ];
15447
+ if (fact.headings === 0 && fact.words >= minimumWords)
15448
+ return [
15449
+ {
15450
+ path: fact.path,
15451
+ code: QUALITY_CODES.SECTION_UNTITLED,
15452
+ message: `This section runs to ${fact.words} words under no heading, so nothing names it in the outline or the contents.`,
15453
+ suggestion: "Open the section with a heading \u2014 a section-opener block, or a level-1 heading.",
15454
+ context: { index: fact.index, words: fact.words },
15455
+ evidence: {
15456
+ actual: 0,
15457
+ expected: 1,
15458
+ unit: "headings",
15459
+ values: { source: "profile" }
15460
+ }
15461
+ }
15462
+ ];
15463
+ return [];
15464
+ });
15465
+ }
15466
+ };
15467
+ var docxHeadingKeepNextRule = {
15468
+ id: "docx/heading-keep-next",
15469
+ description: "A heading that is not bound to the content under it, so a page break can strand it; the fix sets keepNext on an authored heading. Off until a profile or policy enables it.",
15470
+ code: QUALITY_CODES.HEADING_ORPHAN,
15471
+ category: "hierarchy",
15472
+ defaultSeverity: "warning",
15473
+ defaultCertainty: "deterministic",
15474
+ formats: ["docx"],
15475
+ defaultEnabled: false,
15476
+ evaluate: ({ facts }) => facts.filter(
15477
+ (fact) => fact.kind === "docx/heading" && !fact.keepNext
15478
+ ).map((fact) => {
15479
+ const heading = /\/props\/level$/.test(fact.path) ? fact.path.replace(/\/props\/level$/, "") : void 0;
15480
+ return {
15481
+ path: heading ?? fact.path,
15482
+ message: "This heading is not bound to the content under it: a page break can leave it alone at the foot of a page.",
15483
+ suggestion: heading ? "Set keepNext on the heading, or give the theme\u2019s heading style keepNext so every level is bound." : "Give the theme\u2019s heading style keepNext, or set it in the block definition that draws this heading.",
15484
+ context: { level: fact.level },
15485
+ evidence: { values: { source: "profile" } },
15486
+ ...heading && {
15487
+ fixes: [
15488
+ {
15489
+ op: "add",
15490
+ path: `${heading}/props/keepNext`,
15491
+ value: true
15492
+ }
15493
+ ]
15494
+ }
15495
+ };
15496
+ })
15497
+ };
15498
+ var docxFigureLabelRule = {
15499
+ id: "docx/figure-label",
15500
+ description: "An image with neither a caption beside it nor alt text on it. Off until a profile or policy enables it.",
15501
+ code: QUALITY_CODES.FIGURE_UNLABELLED,
15502
+ category: "accessibility",
15503
+ defaultSeverity: "warning",
15504
+ defaultCertainty: "deterministic",
15505
+ formats: ["docx"],
15506
+ defaultEnabled: false,
15507
+ evaluate: ({ facts }) => facts.filter(
15508
+ (fact) => fact.kind === "docx/image" && fact.alt === void 0 && !fact.captioned
15509
+ ).map((fact) => ({
15510
+ path: fact.path,
15511
+ message: "This figure carries neither a caption nor alt text, so nothing in the document says what it shows.",
15512
+ suggestion: "Put the image in a figure block, which numbers and captions it, or write alt text on the image.",
15513
+ context: {}
15514
+ }))
15515
+ };
15516
+ var docxImageAspectRule = {
15517
+ id: "docx/image-aspect",
15518
+ description: "An image drawn at an aspect the asset does not have, where the asset can be read from the document.",
15519
+ code: QUALITY_CODES.IMAGE_ASPECT,
15520
+ category: "integrity",
15521
+ defaultSeverity: "warning",
15522
+ defaultCertainty: "deterministic",
15523
+ formats: ["docx"],
15524
+ defaultParameters: { tolerance: DEFAULT_IMAGE_ASPECT_TOLERANCE },
15525
+ evaluate: ({ facts, configuration }) => {
15526
+ const tolerance = numberParameter(
15527
+ configuration.parameters,
15528
+ "tolerance",
15529
+ DEFAULT_IMAGE_ASPECT_TOLERANCE
15530
+ );
15531
+ return facts.filter(
15532
+ (fact) => fact.kind === "docx/image" && fact.drawnRatio !== void 0 && fact.naturalRatio !== void 0
15533
+ ).flatMap((fact) => {
15534
+ const finding = imageAspectFinding(
15535
+ {
15536
+ path: fact.path,
15537
+ drawn: fact.drawnRatio,
15538
+ natural: fact.naturalRatio
15329
15539
  },
15330
- fixes: [{ op: "replace", path: sizePath, value: expected }]
15331
- });
15332
- }
15333
- return findings;
15540
+ "page",
15541
+ tolerance
15542
+ );
15543
+ return finding ? [finding] : [];
15544
+ });
15545
+ }
15546
+ };
15547
+ var docxContentsRule = {
15548
+ id: "docx/contents-missing",
15549
+ description: "More headings than minimumHeadings with no table of contents. Off at 0.",
15550
+ code: QUALITY_CODES.CONTENTS_MISSING,
15551
+ category: "hierarchy",
15552
+ defaultSeverity: "info",
15553
+ defaultCertainty: "deterministic",
15554
+ formats: ["docx"],
15555
+ defaultParameters: { minimumHeadings: 0 },
15556
+ evaluate: ({ facts, configuration, profile }) => {
15557
+ const minimum = numberParameter(
15558
+ configuration.parameters,
15559
+ "minimumHeadings",
15560
+ 0
15561
+ );
15562
+ if (minimum <= 0) return [];
15563
+ const outline = facts.find(
15564
+ (fact) => fact.kind === "docx/outline"
15565
+ );
15566
+ if (!outline || outline.contents || outline.headings < minimum) return [];
15567
+ return [
15568
+ {
15569
+ path: outline.path,
15570
+ message: `The document carries ${outline.headings} headings and no table of contents; the ${profile?.id ?? "selected"} profile expects one from ${minimum}.`,
15571
+ suggestion: "Add a `toc` component after the cover. Word fills it from the headings already there.",
15572
+ context: { headings: outline.headings, minimum },
15573
+ evidence: {
15574
+ actual: outline.headings,
15575
+ expected: minimum,
15576
+ unit: "headings",
15577
+ values: { source: "profile" }
15578
+ }
15579
+ }
15580
+ ];
15334
15581
  }
15335
15582
  };
15336
15583
  var DOCX_QUALITY_RULES = {
@@ -15353,14 +15600,26 @@ var DOCX_QUALITY_RULES = {
15353
15600
  docxExhibitRequiredRule,
15354
15601
  docxTypeScaleRule,
15355
15602
  docxSizeCountRule,
15356
- docxRoleDriftRule
15603
+ docxRoleDriftRule,
15604
+ docxBodyMeasureRule,
15605
+ docxSectionContentRule,
15606
+ docxHeadingKeepNextRule,
15607
+ docxFigureLabelRule,
15608
+ docxImageAspectRule,
15609
+ docxContentsRule
15357
15610
  ]
15358
15611
  };
15612
+ var REPORT_MEASURE = {
15613
+ "docx/body-measure": {
15614
+ enabled: true,
15615
+ parameters: { maximumCharacters: 125 }
15616
+ }
15617
+ };
15359
15618
  var DOCX_QUALITY_PROFILES = {
15360
15619
  "client-report": {
15361
15620
  id: "client-report",
15362
15621
  formats: ["docx"],
15363
- description: "Client or public-administration report: a running head with page numbers on every section after the cover, a takeaway and a source wherever a block declares them, no heading skipped, every size on the theme scale with at most eight in play, no page rendered empty or left half blank under the running head, and at least one chart or table.",
15622
+ description: "Client or public-administration report: a running head with page numbers on every section after the cover, a takeaway and a source wherever a block declares them, no heading skipped, every size on the theme scale with at most eight in play, a readable measure, no empty or untitled section, every heading bound to what follows and every figure named, no page rendered empty or left half blank under the running head, and at least one chart or table.",
15364
15623
  rules: {
15365
15624
  "docx/required-chrome": {
15366
15625
  parameters: { required: ["takeaway", "source"] }
@@ -15384,7 +15643,11 @@ var DOCX_QUALITY_PROFILES = {
15384
15643
  // judge calls a page two-thirds blank a defect.
15385
15644
  "rendered/page-underfilled": { severity: "warning" },
15386
15645
  // A client report argues numbers: at least one chart or real table.
15387
- "docx/exhibit-required": { enabled: true }
15646
+ "docx/exhibit-required": { enabled: true },
15647
+ ...REPORT_MEASURE,
15648
+ "docx/section-content": { enabled: true },
15649
+ "docx/heading-keep-next": { enabled: true },
15650
+ "docx/figure-label": { enabled: true }
15388
15651
  }
15389
15652
  },
15390
15653
  "executive-report": {
@@ -15392,13 +15655,15 @@ var DOCX_QUALITY_PROFILES = {
15392
15655
  formats: ["docx"],
15393
15656
  description: "Short decision document with strict outline continuity.",
15394
15657
  rules: {
15395
- "docx/heading-hierarchy": { severity: "warning" }
15658
+ "docx/heading-hierarchy": { severity: "warning" },
15659
+ "docx/section-content": { enabled: true },
15660
+ "docx/heading-keep-next": { enabled: true }
15396
15661
  }
15397
15662
  },
15398
15663
  "technical-report": {
15399
15664
  id: "technical-report",
15400
15665
  formats: ["docx"],
15401
- description: "Technical report or memo: numbered sections under a running head with page numbers on every section after the cover, a source wherever a block declares one, no heading skipped, every size on the theme scale with at most nine in play, no page rendered empty or left half blank, and at least one chart or table.",
15666
+ description: "Technical report or memo: numbered sections under a running head with page numbers on every section after the cover, a source wherever a block declares one, no heading skipped, every size on the theme scale with at most nine in play, a readable measure, no empty or untitled section, every heading bound to what follows, every figure named, a contents page past eight headings, no page rendered empty or left half blank, and at least one chart or table.",
15402
15667
  rules: {
15403
15668
  // A figure or table in a technical report cites where its numbers come
15404
15669
  // from; the takeaway is the client report's ask, the caption is the
@@ -15419,7 +15684,17 @@ var DOCX_QUALITY_PROFILES = {
15419
15684
  "rendered/empty-page": { severity: "warning" },
15420
15685
  "rendered/page-underfilled": { severity: "warning" },
15421
15686
  // A technical report argues from measurements: a runs table, a chart.
15422
- "docx/exhibit-required": { enabled: true }
15687
+ "docx/exhibit-required": { enabled: true },
15688
+ ...REPORT_MEASURE,
15689
+ "docx/section-content": { enabled: true },
15690
+ "docx/heading-keep-next": { enabled: true },
15691
+ "docx/figure-label": { enabled: true },
15692
+ // Numbered sections are meant to be reached by number: past eight
15693
+ // headings a technical report owes the reader a contents page.
15694
+ "docx/contents-missing": {
15695
+ severity: "warning",
15696
+ parameters: { minimumHeadings: 8 }
15697
+ }
15423
15698
  }
15424
15699
  },
15425
15700
  general: {