@pptx-studio/validate 0.1.1 → 0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @pptx-studio/validate
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e8ba475: A table is parsed, and its grid is the one PowerPoint draws.
8
+
9
+ `Shape.table` carries the `a:tbl` of a `p:graphicFrame`: the `a:tblPr` flags and style source, every
10
+ `a:gridCol`, every `a:tr` with its cells, and each cell's spans, body and `a:tcPr` as written.
11
+ `tableGrid` resolves the merges by the rule C7 measured (`tables.json`, 76 of 76 tables PowerPoint
12
+ opened as written and 21 it authored itself): each `a:tc` takes the next column, the spans on the
13
+ anchor are the whole story, a covered position is covered whatever its own attributes say, a span
14
+ stops at the edge or at the first claimed position, and `hMerge`/`vMerge` change nothing. `a:tr/@h`
15
+ is a minimum, a column is never narrower than its cells' side margins plus 2 pt, and the frame's
16
+ `a:ext` says nothing about the drawn size. A table attribute of the wrong type throws
17
+ `MODEL_TABLE_ATTR`.
18
+
19
+ `@pptx-studio/validate` gains `V030`, a warning on a table whose rows, spans and flags disagree —
20
+ PowerPoint opens it silently and rewrites it on the next save — and `V031`, fatal on the missing or
21
+ mistyped table attributes PowerPoint repairs. The downstream packages carry the new rules and the
22
+ new field; nothing they draw changes.
23
+
24
+ ## 0.1.2
25
+
26
+ ### Patch Changes
27
+
28
+ - 88cdb45: V028 no longer mistakes the extent of a transform for an extension.
29
+
30
+ The rule holds `mc:AlternateContent` and every `ext` inside an `extLst` opaque, by local name so
31
+ that vocabularies never read are covered too. It matched any element spelled `ext`, and the
32
+ extent inside `a:xfrm` is spelled `ext` as well - so a placeholder given a transform of its own,
33
+ which is what PowerPoint writes when one is dragged, was refused as a rebuilt extension. An `ext`
34
+ is now an extension only under an `extLst`.
35
+
3
36
  ## 0.1.1
4
37
 
5
38
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pptx-studio/validate
2
2
 
3
- **The repair firewall.** Twenty-nine rules a `.pptx` must not break, checked
3
+ **The repair firewall.** Thirty-one rules a `.pptx` must not break, checked
4
4
  before any bytes are handed over, each finding carrying the part and the XPath.
5
5
 
6
6
  Apache-2.0 · browser and Web Worker only, no Node · part of
@@ -28,7 +28,7 @@ quietly lost something.
28
28
  So this is not a schema validator with a nice report. It is the only feedback
29
29
  loop that exists, and it runs on every export in development and in production.
30
30
 
31
- ## Twenty-nine rules, in two halves
31
+ ## Thirty-one rules, in two halves
32
32
 
33
33
  Roughly half come from ECMA-376: content-type coverage, `xsd:sequence` child
34
34
  order, `minOccurs`, the four identifier ranges. Those are derivable, and one of
@@ -38,18 +38,19 @@ schemas and checked against 194 148 elements PowerPoint wrote.
38
38
  The other half cannot be derived from anything. Each is the record of a package
39
39
  built with **one** change in it, opened in PowerPoint 16.0.20326, and declined:
40
40
 
41
- | | |
42
- | ------ | ---------------------------------------------------------------------------------------------- |
43
- | `V022` | a `p:ph type="hdr"` on a slide. The other seven content types all open. |
44
- | `V023` | a geometry guide referenced but never defined. `ST_GeomGuideName` is an unconstrained token. |
45
- | `V024` | a `c:strLit` inside a series `c:tx`, two elements away from where the literal forms are legal. |
46
- | `V025` | a `p:control`, in all eight forms tried. An empty `p:controls` is accepted. |
47
- | `V026` | a `cs:chartStyle` with thirty of its thirty-one entries. No chart style at all is fine. |
48
- | `V019` | a master id colliding with a layout id. They are one number space and no schema says so. |
49
- | `V020` | a `p:cNvPr/@id` between 2147483648 and 4294967294. 4294967295 opens; it is minus one. |
50
- | `V009` | a slide without exactly one layout relationship; a `cx:chartSpace` with no `.rels`. |
51
- | `V004` | a percent-escape of an _unreserved_ character in a part name. `0x808D1005`. |
52
- | `V012` | an `a:ahXY` directly under `a:custGeom`, without its `a:ahLst` wrapper. |
41
+ | | |
42
+ | ------ | ---------------------------------------------------------------------------------------------------- |
43
+ | `V022` | a `p:ph type="hdr"` on a slide. The other seven content types all open. |
44
+ | `V023` | a geometry guide referenced but never defined. `ST_GeomGuideName` is an unconstrained token. |
45
+ | `V024` | a `c:strLit` inside a series `c:tx`, two elements away from where the literal forms are legal. |
46
+ | `V025` | a `p:control`, in all eight forms tried. An empty `p:controls` is accepted. |
47
+ | `V026` | a `cs:chartStyle` with thirty of its thirty-one entries. No chart style at all is fine. |
48
+ | `V019` | a master id colliding with a layout id. They are one number space and no schema says so. |
49
+ | `V020` | a `p:cNvPr/@id` between 2147483648 and 4294967294. 4294967295 opens; it is minus one. |
50
+ | `V009` | a slide without exactly one layout relationship; a `cx:chartSpace` with no `.rels`. |
51
+ | `V004` | a percent-escape of an _unreserved_ character in a part name. `0x808D1005`. |
52
+ | `V012` | an `a:ahXY` directly under `a:custGeom`, without its `a:ahLst` wrapper. |
53
+ | `V031` | an `a:gridCol` without `@w`, an `a:tr` without `@h`, `gridSpan="2.0"`, `hMerge="on"`: a repair each. |
53
54
 
54
55
  Every rule carries a `why` recording what was tried and what opened, so whoever
55
56
  eventually contradicts one knows what they are contradicting.
@@ -85,7 +86,7 @@ the same reason.
85
86
 
86
87
  Two suites that check opposite things, and neither substitutes for the other.
87
88
 
88
- **It fires.** `validate.test.ts` breaks a minimal deck twenty-nine ways, one per
89
+ **It fires.** `validate.test.ts` breaks a minimal deck thirty-one ways, one per
89
90
  rule, and asserts each rule reports the right part and the right XPath.
90
91
 
91
92
  **It is quiet.** `tools/corpus/suites/validate.test.ts` runs all fifty-two committed
package/dist/index.d.ts CHANGED
@@ -49,9 +49,9 @@ declare function isValidateError(value: unknown): value is ValidateError;
49
49
  //#endregion
50
50
  //#region src/rules/rules.d.ts
51
51
  /**
52
- * The twenty-nine rules, and the evidence for each.
52
+ * The thirty-one rules, and the evidence for each.
53
53
  *
54
- * ## Why a table and not twenty-nine functions
54
+ * ## Why a table and not thirty-one functions
55
55
  *
56
56
  * The functions are in the files beside this one. What lives here is the part a
57
57
  * person reads: what each rule claims, how sure we are, and *how we know*. A
@@ -223,7 +223,7 @@ declare const RULES: readonly [{
223
223
  readonly id: "V016";
224
224
  readonly category: "required";
225
225
  readonly severity: "fatal";
226
- readonly evidence: "schema";
226
+ readonly evidence: "both";
227
227
  readonly title: "every text body has `a:bodyPr` and at least one `a:p`";
228
228
  readonly why: string;
229
229
  }, {
@@ -320,6 +320,20 @@ declare const RULES: readonly [{
320
320
  readonly needsBaseline: true;
321
321
  readonly title: "text and field identity survive: `xml:space`, `a:fld/@id`, cached field text";
322
322
  readonly why: string;
323
+ }, {
324
+ readonly id: "V030";
325
+ readonly category: "required";
326
+ readonly severity: "warning";
327
+ readonly evidence: "measured";
328
+ readonly title: "a table is the one PowerPoint reads: a cell per column in every row, a flag where a span covers";
329
+ readonly why: string;
330
+ }, {
331
+ readonly id: "V031";
332
+ readonly category: "required";
333
+ readonly severity: "fatal";
334
+ readonly evidence: "both";
335
+ readonly title: "`a:gridCol/@w`, `a:tr/@h`, and typed `gridSpan`, `rowSpan`, `hMerge`, `vMerge`";
336
+ readonly why: string;
323
337
  }];
324
338
  type RuleId = (typeof RULES)[number]['id'];
325
339
  declare function ruleById(id: string): Rule | undefined;
@@ -443,7 +457,7 @@ declare function rootName(document: XDocument): string;
443
457
  * never saved again. The editor would be refusing to give the user back their
444
458
  * own file over a problem it did not cause and cannot fix. `PartStore.write`
445
459
  * already made exactly this call for dangling relationships, and this is the
446
- * same call generalised to all twenty-nine rules.
460
+ * same call generalised to all thirty-one rules.
447
461
  *
448
462
  * So `origin` is not a per-rule judgement call. It is computed by running the
449
463
  * rules a second time against the package **as it was opened** and differencing
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/errors.ts","../src/rules/rules.ts","../src/report/location.ts","../src/report/report.ts","../src/context.ts","../src/validate.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/errors.ts","../src/rules/rules.ts","../src/report/location.ts","../src/report/report.ts","../src/context.ts","../src/validate.ts"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -169,9 +169,9 @@ const RULES = [
169
169
  id: "V016",
170
170
  category: "required",
171
171
  severity: "fatal",
172
- evidence: "schema",
172
+ evidence: "both",
173
173
  title: "every text body has `a:bodyPr` and at least one `a:p`",
174
- why: "`CT_TextBody` is `bodyPr` then optional `lstStyle` then `a:p` at `[1..unbounded]`. A shape whose text was deleted down to nothing is the way this gets broken in an editor: the last paragraph goes, and what is left is a `p:txBody` with no `a:p` in it. An empty paragraph is not the same as no paragraph, and `a:endParaRPr` is where its height comes from."
174
+ why: "`CT_TextBody` is `bodyPr` then optional `lstStyle` then `a:p` at `[1..unbounded]`. A shape whose text was deleted down to nothing is the way this gets broken in an editor: the last paragraph goes, and what is left is a `p:txBody` with no `a:p` in it. An empty paragraph is not the same as no paragraph, and `a:endParaRPr` is where its height comes from. C7 measured it on a table cell: a repair prompt, and PowerPoint writes the paragraph in."
175
175
  },
176
176
  {
177
177
  id: "V017",
@@ -279,6 +279,22 @@ const RULES = [
279
279
  needsBaseline: true,
280
280
  title: "text and field identity survive: `xml:space`, `a:fld/@id`, cached field text",
281
281
  why: "Three small things that are each silently destructive. An `a:t` with leading or trailing whitespace needs `xml:space=\"preserve\"` if it was written with it, because dropping the attribute changes what the text *is*. `a:fld/@id` is a required `ST_Guid` and regenerating one can make PowerPoint repair the file. And a field’s cached `a:t` is the only thing that renders when we cannot evaluate the field ourselves, so discarding it turns a date placeholder into an empty box on every consumer that is not PowerPoint."
282
+ },
283
+ {
284
+ id: "V030",
285
+ category: "required",
286
+ severity: "warning",
287
+ evidence: "measured",
288
+ title: "a table is the one PowerPoint reads: a cell per column in every row, a flag where a span covers",
289
+ why: "Nothing in the schema ties an `a:tr` to the number of `a:gridCol`, or a `gridSpan` on one cell to the `hMerge` on the next. C7 built 82 tables, 52 of them to disagree with themselves, and asked PowerPoint which cell every grid position belongs to: the spans on the anchor are the whole answer (76 of 76, against 52 for the flags), a short row is padded, a fifth cell is dropped, a colliding or oversized span is clamped and a covered cell’s own span ignored - and PowerPoint writes all of that back on save. The deck opens without a word, which is why this is a warning: the file says one table and PowerPoint draws another, and the next save is the one that rewrites the part."
290
+ },
291
+ {
292
+ id: "V031",
293
+ category: "required",
294
+ severity: "fatal",
295
+ evidence: "both",
296
+ title: "`a:gridCol/@w`, `a:tr/@h`, and typed `gridSpan`, `rowSpan`, `hMerge`, `vMerge`",
297
+ why: "`CT_TableCol/@w` and `CT_TableRow/@h` are required `ST_Coordinate`s, the spans are `xsd:int` and the flags `xsd:boolean`. C7 measured each: a `gridCol` without `@w`, a `tr` without `@h`, `gridSpan=\"2.0\"` and `hMerge=\"on\"` are a repair prompt apiece; PowerPoint saves the column at its minimum width, the row at zero, the span gone and the flag as \"1\". `w=\"108pt\"`, the universal-measure spelling, opens clean and is not this rule."
282
298
  }
283
299
  ];
284
300
  const BY_ID = new Map(RULES.map((rule) => [rule.id, rule]));
@@ -629,7 +645,7 @@ function readRelsParts(ctx) {
629
645
  return parts;
630
646
  }
631
647
  /**
632
- * Memoised above, because five of the twenty-nine rules want this list and each
648
+ * Memoised above, because five of the thirty-one rules want this list and each
633
649
  * would otherwise re-walk every `.rels` part in the package. The parsed trees
634
650
  * are already cached on the context; what is saved here is the element scan and
635
651
  * the allocation, which on a three-hundred-slide deck is five passes over a
@@ -1153,7 +1169,7 @@ function forEachElement$1(ctx, visit) {
1153
1169
  //#endregion
1154
1170
  //#region src/rules/required.ts
1155
1171
  /**
1156
- * `V013` … `V017`: children and attributes that are not optional.
1172
+ * `V013` … `V017`, `V030` and `V031`: children and attributes that are not optional.
1157
1173
  *
1158
1174
  * Every one of these is a `minOccurs` the schema states plainly, and every one
1159
1175
  * is broken the same way: by an editor deleting the last of something. The last
@@ -1226,6 +1242,140 @@ function v017GraphicFrame(ctx) {
1226
1242
  if (childElements(element).find((child) => child.local === "graphic" && namespaceOf(child) === NS.a) === void 0) ctx.add("V017", elementLocation(part, element), "no <a:graphic>. The frame is the wrapper; the graphic is the thing in it, and a frame without one names no table, chart, diagram or OLE object at all.");
1227
1243
  });
1228
1244
  }
1245
+ /** The lexical forms of `xsd:boolean`. */
1246
+ const BOOLEANS = /* @__PURE__ */ new Set([
1247
+ "1",
1248
+ "0",
1249
+ "true",
1250
+ "false"
1251
+ ]);
1252
+ const INT = /^[+-]?\d+$/;
1253
+ /** `a:gridCol` carries `@w`, `a:tr` carries `@h`, and a cell's four merge attributes are typed. */
1254
+ function v031TableAttributes(ctx) {
1255
+ forEachElement(ctx, (part, element) => {
1256
+ if (namespaceOf(element) !== NS.a) return;
1257
+ const missing = (name) => {
1258
+ ctx.add("V031", elementLocation(part, element), "<" + element.qname + "> has no @" + name + ". CT_TableCol and CT_TableRow require it, and C7 measured the repair prompt: PowerPoint opens the deck only after rewriting the part.");
1259
+ };
1260
+ if (element.local === "gridCol" && attribute(element, "w") === void 0) missing("w");
1261
+ if (element.local === "tr" && attribute(element, "h") === void 0) missing("h");
1262
+ if (element.local !== "tc") return;
1263
+ for (const [name, ok, type] of [
1264
+ [
1265
+ "gridSpan",
1266
+ INT,
1267
+ "xsd:int"
1268
+ ],
1269
+ [
1270
+ "rowSpan",
1271
+ INT,
1272
+ "xsd:int"
1273
+ ],
1274
+ [
1275
+ "hMerge",
1276
+ BOOLEANS,
1277
+ "xsd:boolean"
1278
+ ],
1279
+ [
1280
+ "vMerge",
1281
+ BOOLEANS,
1282
+ "xsd:boolean"
1283
+ ]
1284
+ ]) {
1285
+ const raw = attribute(element, name)?.value;
1286
+ if (raw === void 0 || (ok instanceof RegExp ? ok.test(raw) : ok.has(raw))) continue;
1287
+ ctx.add("V031", elementLocation(part, element), "<a:tc>/@" + name + " is \"" + raw + "\", which is not an " + type + ". C7 measured gridSpan=\"2.0\" and hMerge=\"on\": a repair prompt each; the span is dropped with the merge it named and the flag comes back as \"1\" under its span.");
1288
+ }
1289
+ });
1290
+ }
1291
+ /** Zero is one and a negative runs to the edge: C7's span-zero and span-negative. */
1292
+ function spanOf(raw) {
1293
+ if (raw === void 0) return 1;
1294
+ const n = Number(raw);
1295
+ return n < 0 ? Number.POSITIVE_INFINITY : Math.max(1, n);
1296
+ }
1297
+ function flagged(cell, name) {
1298
+ const raw = attribute(cell, name)?.value;
1299
+ return raw === "1" || raw === "true";
1300
+ }
1301
+ /**
1302
+ * The occupancy `@pptx-studio/model` ships, over attributes: each cell takes the next column, a
1303
+ * claimed position is covered whatever it says, a span stops at the edge or at a claimed position.
1304
+ */
1305
+ function owners(cells, cols) {
1306
+ const grid = cells.map(() => Array.from({ length: cols }).fill(null));
1307
+ cells.forEach((line, r) => {
1308
+ line.forEach((cell, c) => {
1309
+ if (c >= cols || grid[r][c] !== null) return;
1310
+ const wide = spanOf(attribute(cell, "gridSpan")?.value);
1311
+ const tall = spanOf(attribute(cell, "rowSpan")?.value);
1312
+ const owner = {
1313
+ row: r,
1314
+ col: c,
1315
+ rows: 1,
1316
+ cols: 1
1317
+ };
1318
+ while (c + owner.cols < cols && owner.cols < wide && grid[r][c + owner.cols] === null) owner.cols += 1;
1319
+ owner.rows = Math.min(tall, cells.length - r);
1320
+ for (let rr = r; rr < r + owner.rows; rr++) for (let cc = c; cc < c + owner.cols; cc++) grid[rr][cc] = owner;
1321
+ });
1322
+ });
1323
+ return grid;
1324
+ }
1325
+ /** The table PowerPoint reads: one cell per column in every row, a flag exactly where a span covers. */
1326
+ function v030TableGrid(ctx) {
1327
+ forEachElement(ctx, (part, element) => {
1328
+ if (element.local !== "tbl" || namespaceOf(element) !== NS.a) return;
1329
+ const add = (where, message) => {
1330
+ ctx.add("V030", elementLocation(part, where), message);
1331
+ };
1332
+ const grid = childElements(element).find((child) => child.local === "tblGrid");
1333
+ const cols = grid === void 0 ? 0 : childElements(grid).filter((c) => c.local === "gridCol").length;
1334
+ const rows = childElements(element).filter((child) => child.local === "tr");
1335
+ if (cols === 0 || rows.length === 0) {
1336
+ add(element, "the table has " + String(cols) + " column(s) and " + String(rows.length) + " row(s). PowerPoint invents one of whichever is missing, empty, and writes it back - measured in C7.");
1337
+ return;
1338
+ }
1339
+ const cells = rows.map((row) => childElements(row).filter((child) => child.local === "tc"));
1340
+ cells.forEach((line, r) => {
1341
+ if (line.length === cols) return;
1342
+ add(rows[r], "row " + String(r + 1) + " holds " + String(line.length) + " cell(s) for " + String(cols) + " column(s). PowerPoint pads a short row with an empty cell, drops the cells past the last column, and writes the result back - measured in C7.");
1343
+ });
1344
+ const grid2 = owners(cells, cols);
1345
+ cells.forEach((line, r) => {
1346
+ line.forEach((cell, c) => {
1347
+ const owner = grid2[r]?.[c];
1348
+ if (owner === null || owner === void 0) return;
1349
+ const at = "cell (" + String(r + 1) + ", " + String(c + 1) + ")";
1350
+ if (owner.row === r && owner.col === c) {
1351
+ for (const [name, span] of [["gridSpan", owner.cols], ["rowSpan", owner.rows]]) {
1352
+ const written = attribute(cell, name)?.value;
1353
+ if (written === void 0 || Number(written) === span) continue;
1354
+ add(cell, at + " says " + name + "=\"" + written + "\" and covers " + String(span) + ". A span stops at the grid edge or at a position an earlier span claimed, zero is one and a negative runs to the edge; PowerPoint writes the span it drew - measured in C7.");
1355
+ }
1356
+ for (const name of ["hMerge", "vMerge"]) {
1357
+ if (!flagged(cell, name)) continue;
1358
+ add(cell, at + " says " + name + "=\"1\" and no span covers it. The flag changes nothing: PowerPoint reads the spans alone, 76 of 76 in C7, and drops the flag on save.");
1359
+ }
1360
+ return;
1361
+ }
1362
+ for (const [name, covered] of [["hMerge", owner.col < c], ["vMerge", owner.row < r]]) {
1363
+ if (covered === flagged(cell, name)) continue;
1364
+ if (!covered) {
1365
+ add(cell, at + " says " + name + "=\"1\" and the span of cell (" + String(owner.row + 1) + ", " + String(owner.col + 1) + ") covers it the other way. PowerPoint drops the flag on save - measured in C7.");
1366
+ continue;
1367
+ }
1368
+ add(cell, at + " is covered by the span of cell (" + String(owner.row + 1) + ", " + String(owner.col + 1) + ") and does not say " + name + "=\"1\". PowerPoint reads it as covered anyway and adds the flag on save; a reader that trusts the flag draws a cell PowerPoint does not - measured in C7.");
1369
+ }
1370
+ for (const [name, allowed] of [["gridSpan", c === owner.col ? owner.cols : null], ["rowSpan", r === owner.row ? owner.rows : null]]) {
1371
+ const written = attribute(cell, name)?.value;
1372
+ if (written === void 0 || Number(written) === allowed) continue;
1373
+ add(cell, at + " is covered and says " + name + "=\"" + written + "\". A covered cell’s own span is ignored and PowerPoint deletes it on save unless it repeats the anchor’s other span, as PowerPoint’s own form does - measured in C7.");
1374
+ }
1375
+ });
1376
+ });
1377
+ });
1378
+ }
1229
1379
  /**
1230
1380
  * Presentation-family parts only.
1231
1381
  *
@@ -1823,9 +1973,9 @@ function sameBytes(a, b) {
1823
1973
  for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
1824
1974
  return true;
1825
1975
  }
1826
- /** Local names of the containers held opaque, with the namespace-free test that finds them. */
1976
+ /** The containers held opaque, found by local name so that vocabularies never read are covered too. */
1827
1977
  function isOpaqueContainer(element) {
1828
- return element.local === "AlternateContent" || element.local === "ext";
1978
+ return element.local === "AlternateContent" || element.local === "ext" && element.parent?.local === "extLst";
1829
1979
  }
1830
1980
  /** Every opaque container in a document, as its exact source text. */
1831
1981
  function opaqueSources(document) {
@@ -1959,7 +2109,7 @@ function v029TextAndFieldIdentity(ctx) {
1959
2109
  //#endregion
1960
2110
  //#region src/validate.ts
1961
2111
  /**
1962
- * The twenty-nine, wired to their implementations.
2112
+ * The thirty-one, wired to their implementations.
1963
2113
  *
1964
2114
  * A plain table, so that "is every rule reachable" is a thing a test can ask
1965
2115
  * rather than a thing a reader has to believe. `rules.test.ts` asserts that the
@@ -1995,7 +2145,9 @@ const IMPLEMENTATIONS = {
1995
2145
  V026: v026ChartStyleComplete,
1996
2146
  V027: v027UneditedPartsUnchanged,
1997
2147
  V028: v028OpaqueContainersUnchanged,
1998
- V029: v029TextAndFieldIdentity
2148
+ V029: v029TextAndFieldIdentity,
2149
+ V030: v030TableGrid,
2150
+ V031: v031TableAttributes
1999
2151
  };
2000
2152
  /** Rules that cannot run without the archive bytes. See `Context.archive`. */
2001
2153
  const ARCHIVE_RULES = ["V003"];