@usejunior/docx-core 0.23.0 → 0.24.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.
Files changed (40) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/generation/emit/document-part.d.ts +7 -5
  3. package/dist/generation/emit/document-part.d.ts.map +1 -1
  4. package/dist/generation/emit/document-part.js +23 -5
  5. package/dist/generation/emit/document-part.js.map +1 -1
  6. package/dist/generation/emit/font-table-part.d.ts +3 -2
  7. package/dist/generation/emit/font-table-part.d.ts.map +1 -1
  8. package/dist/generation/emit/font-table-part.js +7 -6
  9. package/dist/generation/emit/font-table-part.js.map +1 -1
  10. package/dist/generation/emit/properties.d.ts +2 -2
  11. package/dist/generation/emit/properties.d.ts.map +1 -1
  12. package/dist/generation/emit/properties.js +11 -10
  13. package/dist/generation/emit/properties.js.map +1 -1
  14. package/dist/generation/emit/styles-part.d.ts +2 -0
  15. package/dist/generation/emit/styles-part.d.ts.map +1 -1
  16. package/dist/generation/emit/styles-part.js +28 -13
  17. package/dist/generation/emit/styles-part.js.map +1 -1
  18. package/dist/generation/index.d.ts +1 -1
  19. package/dist/generation/index.d.ts.map +1 -1
  20. package/dist/generation/types.d.ts +23 -1
  21. package/dist/generation/types.d.ts.map +1 -1
  22. package/dist/generation/types.js.map +1 -1
  23. package/dist/generation/validate-spec.d.ts.map +1 -1
  24. package/dist/generation/validate-spec.js +25 -0
  25. package/dist/generation/validate-spec.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/primitives/index.d.ts +1 -0
  29. package/dist/primitives/index.d.ts.map +1 -1
  30. package/dist/primitives/index.js +1 -0
  31. package/dist/primitives/index.js.map +1 -1
  32. package/dist/primitives/structural_validation.d.ts +54 -0
  33. package/dist/primitives/structural_validation.d.ts.map +1 -0
  34. package/dist/primitives/structural_validation.js +301 -0
  35. package/dist/primitives/structural_validation.js.map +1 -0
  36. package/dist/primitives/styles.d.ts +39 -17
  37. package/dist/primitives/styles.d.ts.map +1 -1
  38. package/dist/primitives/styles.js +412 -89
  39. package/dist/primitives/styles.js.map +1 -1
  40. package/package.json +2 -2
@@ -5,6 +5,49 @@ function getWAttr(el, localName) {
5
5
  // when attributes were written without a real namespace binding.
6
6
  return getAttributeSafe(el, OOXML.W_NS, localName, 'w', { emptyIsMissing: true });
7
7
  }
8
+ function isOnValue(v) {
9
+ return v !== '0' && v !== 'false' && v !== 'off';
10
+ }
11
+ function childElements(parent, localName) {
12
+ const out = [];
13
+ for (let c = parent.firstChild; c; c = c.nextSibling) {
14
+ if (c.nodeType !== 1)
15
+ continue;
16
+ const el = c;
17
+ if (el.localName === localName && el.namespaceURI === OOXML.W_NS)
18
+ out.push(el);
19
+ }
20
+ return out;
21
+ }
22
+ /**
23
+ * The first direct `w:` child named `localName`. Unlike getFirstChild (a
24
+ * descendant search) it never reaches into a nested `w:tblPrChange` /
25
+ * `w:trPrChange` (the previous state) or a nested table.
26
+ */
27
+ function ownChild(parent, localName) {
28
+ for (let c = parent.firstChild; c; c = c.nextSibling) {
29
+ if (c.nodeType !== 1)
30
+ continue;
31
+ const el = c;
32
+ if (el.localName === localName && el.namespaceURI === OOXML.W_NS)
33
+ return el;
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * A table-style `w:rPr` without its `w:rPrChange`, which records the
39
+ * previous state of a tracked formatting change. The property readers search
40
+ * descendants, so the change record would otherwise read as current. The
41
+ * copy is detached and only ever read.
42
+ */
43
+ function currentRPr(rPr) {
44
+ if (!rPr || !ownChild(rPr, 'rPrChange'))
45
+ return rPr;
46
+ const copy = rPr.cloneNode(true);
47
+ for (const change of childElements(copy, 'rPrChange'))
48
+ copy.removeChild(change);
49
+ return copy;
50
+ }
8
51
  const THEME_COLOR_ELEMENT_BY_REFERENCE = {
9
52
  dark1: 'dk1',
10
53
  light1: 'lt1',
@@ -78,11 +121,12 @@ export function parseThemeXml(themeDoc) {
78
121
  export function parseStylesXml(stylesDoc) {
79
122
  const byId = new Map();
80
123
  if (!stylesDoc)
81
- return { byId, docDefaultsRPr: null, tableStyleRPrs: [] };
124
+ return { byId, docDefaultsRPr: null, defaultTableStyleId: null, tableStyleRPrs: [] };
82
125
  const docDefaults = stylesDoc.getElementsByTagNameNS(OOXML.W_NS, W.docDefaults).item(0);
83
126
  const rPrDefault = docDefaults ? getFirstChild(docDefaults, OOXML.W_NS, W.rPrDefault) : null;
84
127
  const docDefaultsRPr = rPrDefault ? getFirstChild(rPrDefault, OOXML.W_NS, W.rPr) : null;
85
128
  const tableStyleRPrs = [];
129
+ let defaultTableStyleId = null;
86
130
  const styles = Array.from(stylesDoc.getElementsByTagNameNS(OOXML.W_NS, W.style));
87
131
  for (const st of styles) {
88
132
  const id = getWAttr(st, 'styleId');
@@ -90,30 +134,48 @@ export function parseStylesXml(stylesDoc) {
90
134
  continue;
91
135
  const nameEl = getFirstChild(st, OOXML.W_NS, W.name);
92
136
  const basedOnEl = getFirstChild(st, OOXML.W_NS, W.basedOn);
93
- const pPr = getFirstChild(st, OOXML.W_NS, W.pPr);
94
- const rPr = getFirstChild(st, OOXML.W_NS, W.rPr);
137
+ const styleType = getWAttr(st, 'type');
138
+ // getFirstChild searches descendants. A table style nests further pPr /
139
+ // rPr / tblPr inside each w:tblStylePr, so its own properties must be read
140
+ // from direct children, or a style without its own rPr would take the
141
+ // first conditional's as if it applied to the whole table.
142
+ const styleChild = (localName) => styleType === 'table' ? ownChild(st, localName) : getFirstChild(st, OOXML.W_NS, localName);
143
+ const pPr = styleChild(W.pPr);
144
+ const rPr = styleChild(W.rPr);
95
145
  const name = nameEl ? (getWAttr(nameEl, 'val') ?? id) : id;
96
146
  const basedOn = basedOnEl ? (getWAttr(basedOnEl, 'val') ?? null) : null;
97
- const styleType = getWAttr(st, 'type');
98
- if (styleType === 'table') {
99
- if (rPr)
100
- tableStyleRPrs.push(rPr);
101
- for (const conditional of Array.from(st.getElementsByTagNameNS(OOXML.W_NS, 'tblStylePr'))) {
102
- const conditionalRPr = getFirstChild(conditional, OOXML.W_NS, W.rPr);
103
- if (conditionalRPr)
104
- tableStyleRPrs.push(conditionalRPr);
105
- }
106
- }
107
- byId.set(id, {
147
+ const def = {
108
148
  styleId: id,
109
149
  styleType,
110
150
  name,
111
151
  basedOn,
112
152
  pPr: pPr ?? null,
113
- rPr: rPr ?? null,
114
- });
153
+ rPr: (styleType === 'table' ? currentRPr(rPr) : rPr) ?? null,
154
+ };
155
+ if (styleType === 'table') {
156
+ if (def.rPr)
157
+ tableStyleRPrs.push(def.rPr);
158
+ const conditionalRPrs = new Map();
159
+ for (const conditional of childElements(st, 'tblStylePr')) {
160
+ const type = getWAttr(conditional, 'type');
161
+ const conditionalRPr = currentRPr(ownChild(conditional, W.rPr));
162
+ if (!conditionalRPr)
163
+ continue;
164
+ tableStyleRPrs.push(conditionalRPr);
165
+ // The first block of a type wins, matching getFirstChild elsewhere.
166
+ if (type && !conditionalRPrs.has(type))
167
+ conditionalRPrs.set(type, conditionalRPr);
168
+ }
169
+ def.tblPr = styleChild(W.tblPr);
170
+ def.conditionalRPrs = conditionalRPrs;
171
+ const isDefault = getWAttr(st, 'default');
172
+ // The last default style of a type wins (§ 17.7.4.17).
173
+ if (isDefault !== null && isOnValue(isDefault))
174
+ defaultTableStyleId = id;
175
+ }
176
+ byId.set(id, def);
115
177
  }
116
- return { byId, docDefaultsRPr: docDefaultsRPr ?? null, tableStyleRPrs };
178
+ return { byId, docDefaultsRPr: docDefaultsRPr ?? null, defaultTableStyleId, tableStyleRPrs };
117
179
  }
118
180
  function resolveStyleChain(model, styleId) {
119
181
  const chain = [];
@@ -251,46 +313,310 @@ function parseBoolProp(parent, tagLocal) {
251
313
  * @see https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/f7130225-2368-48f3-acae-a9d278d0fb25
252
314
  * @see https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/f936abaf-a9cb-439b-923d-7f688d9202e9
253
315
  */
254
- function resolveToggleProperty(steps, tagLocal, unreadLayerTurnsOn) {
316
+ function resolveToggleProperty(steps, tagLocal) {
255
317
  let effective = false;
256
- // Once a direct step sets an absolute value, the base the hierarchy started
257
- // from no longer affects the result.
258
- let absolute = false;
259
- for (const { rPr, kind } of steps) {
260
- const declaration = parseBoolProp(rPr, tagLocal);
318
+ for (const { rPr, declare, kind } of steps) {
319
+ const declaration = declare ? declare(tagLocal) : parseBoolProp(rPr ?? null, tagLocal);
261
320
  if (declaration === null)
262
321
  continue;
263
- if (kind === 'default') {
264
- // The seed sits below every unread layer (table styles), so it does not
265
- // make the result absolute.
266
- effective = declaration;
322
+ // A table style resets the toggle to its declared value (Word; see the
323
+ // MS-OI29500 note on § 17.7.6), like the default and direct levels.
324
+ if (kind === 'style') {
325
+ if (declaration)
326
+ effective = !effective;
267
327
  }
268
- else if (kind === 'direct') {
328
+ else {
269
329
  effective = declaration;
270
- absolute = true;
271
- }
272
- else if (declaration) {
273
- effective = !effective;
274
330
  }
275
331
  }
276
- // The walk starts from the document default (or `false`, the OOXML
277
- // default). Table styles sit between that seed and the paragraph style and
278
- // are not read, so a table style that turns the toggle on leaves the result
279
- // depending on a layer the resolver does not read.
280
- if (absolute || !unreadLayerTurnsOn)
281
- return effective;
282
- return null;
332
+ return effective;
333
+ }
334
+ /**
335
+ * Conditional formatting types (ST_TblStyleOverrideType, § 17.18.89) in the
336
+ * order Word applies them; each later type overrides the ones before it.
337
+ * `wholeTable` is not among them: Word neither applies nor keeps a
338
+ * `wholeTable` conditional (MS-OI29500 note on § 17.18.89), and the style's
339
+ * own `w:rPr` is the whole-table formatting.
340
+ *
341
+ * ECMA-376 § 17.7.6 lists whole table, column bands, row bands, first/last
342
+ * row, first/last column, then the corner cells. Microsoft's implementation
343
+ * note for § 17.7.6.6 records that Office instead applies row bands, column
344
+ * bands, first/last column, first/last row, then the corners. This follows
345
+ * Office, the renderer whose output the resolver is meant to report.
346
+ *
347
+ * @conformance ECMA-376 edition 5, Part 1 § 17.7.6
348
+ * @see https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/2ac331d4-cf1e-4fa0-8bca-6da74411e284
349
+ */
350
+ const CONDITIONAL_ORDER = [
351
+ 'band1Horz',
352
+ 'band2Horz',
353
+ 'band1Vert',
354
+ 'band2Vert',
355
+ 'firstCol',
356
+ 'lastCol',
357
+ 'firstRow',
358
+ 'lastRow',
359
+ 'nwCell',
360
+ 'neCell',
361
+ 'swCell',
362
+ 'seCell',
363
+ ];
364
+ const WORD_DEFAULT_TABLE_LOOK = 0x04a0;
365
+ function tableLookFromBits(bits) {
366
+ return {
367
+ firstRow: (bits & 0x0020) !== 0,
368
+ lastRow: (bits & 0x0040) !== 0,
369
+ firstColumn: (bits & 0x0080) !== 0,
370
+ lastColumn: (bits & 0x0100) !== 0,
371
+ hBand: (bits & 0x0200) === 0,
372
+ vBand: (bits & 0x0400) === 0,
373
+ };
283
374
  }
284
- function isInsideTable(node) {
375
+ /**
376
+ * Read `w:tblLook`. The explicit attributes win; the transitional `w:val`
377
+ * hex bitmask is read only when none of them is present (0x0020 first row,
378
+ * 0x0040 last row, 0x0080 first column, 0x0100 last column, 0x0200 no
379
+ * horizontal banding, 0x0400 no vertical banding). An absent attribute is
380
+ * off.
381
+ *
382
+ * When the table has no `w:tblLook`, the standard assumes 0x0000 but Word
383
+ * assumes 0x04A0 (first row, first column, no vertical banding); this
384
+ * follows Word, the renderer whose output the resolver reports.
385
+ *
386
+ * @see https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/90f075ce-b16d-422a-b1b0-39c9777a594e
387
+ */
388
+ function parseTableLook(el) {
389
+ if (!el)
390
+ return tableLookFromBits(WORD_DEFAULT_TABLE_LOOK);
391
+ const named = ['firstRow', 'lastRow', 'firstColumn', 'lastColumn', 'noHBand', 'noVBand'].map((a) => getWAttr(el, a));
392
+ if (named.some((v) => v !== null)) {
393
+ const on = (v) => v !== null && isOnValue(v);
394
+ const [firstRow, lastRow, firstColumn, lastColumn, noHBand, noVBand] = named;
395
+ return {
396
+ firstRow: on(firstRow),
397
+ lastRow: on(lastRow),
398
+ firstColumn: on(firstColumn),
399
+ lastColumn: on(lastColumn),
400
+ hBand: !on(noHBand),
401
+ vBand: !on(noVBand),
402
+ };
403
+ }
404
+ const raw = getWAttr(el, 'val');
405
+ return tableLookFromBits(raw && /^[0-9A-Fa-f]{1,4}$/u.test(raw) ? Number.parseInt(raw, 16) : 0);
406
+ }
407
+ function nearestAncestor(node, localName, stopAt = null) {
285
408
  for (let cur = node.parentNode; cur; cur = cur.parentNode) {
286
409
  if (cur.nodeType !== 1)
287
410
  continue;
288
411
  const el = cur;
289
- if (el.localName === W.tbl && el.namespaceURI === OOXML.W_NS)
290
- return true;
412
+ if (el.namespaceURI !== OOXML.W_NS)
413
+ continue;
414
+ if (el.localName === localName)
415
+ return el;
416
+ if (stopAt && el.localName === stopAt)
417
+ return null;
418
+ }
419
+ return null;
420
+ }
421
+ /**
422
+ * Visit the `w:tr` rows of a table (or `w:tc` cells of a row) in document
423
+ * order, or in reverse with `fromEnd`, looking through the content-control
424
+ * and custom-XML wrappers that may enclose them. Stops early when `visit`
425
+ * returns true.
426
+ */
427
+ function walkTableChildren(parent, localName, visit, fromEnd = false) {
428
+ for (let c = fromEnd ? parent.lastChild : parent.firstChild; c; c = fromEnd ? c.previousSibling : c.nextSibling) {
429
+ if (c.nodeType !== 1 || c.namespaceURI !== OOXML.W_NS)
430
+ continue;
431
+ const child = c;
432
+ if (child.localName === localName) {
433
+ if (visit(child))
434
+ return true;
435
+ }
436
+ else if (child.localName === 'sdt') {
437
+ const content = ownChild(child, 'sdtContent');
438
+ if (content && walkTableChildren(content, localName, visit, fromEnd))
439
+ return true;
440
+ }
441
+ else if (child.localName === 'customXml') {
442
+ if (walkTableChildren(child, localName, visit, fromEnd))
443
+ return true;
444
+ }
291
445
  }
292
446
  return false;
293
447
  }
448
+ function edgeTableChild(parent, localName, fromEnd) {
449
+ let found = null;
450
+ walkTableChildren(parent, localName, (el) => {
451
+ found = el;
452
+ return true;
453
+ }, fromEnd);
454
+ return found;
455
+ }
456
+ function tableChildIndex(parent, localName, target) {
457
+ let index = 0;
458
+ let found = -1;
459
+ walkTableChildren(parent, localName, (el) => {
460
+ if (el === target) {
461
+ found = index;
462
+ return true;
463
+ }
464
+ index++;
465
+ return false;
466
+ });
467
+ return found;
468
+ }
469
+ function intVal(parent, localName, fallback) {
470
+ const el = parent ? ownChild(parent, localName) : null;
471
+ const v = el ? Number.parseInt(getWAttr(el, 'val') ?? '', 10) : Number.NaN;
472
+ return Number.isSafeInteger(v) && v >= 0 ? v : fallback;
473
+ }
474
+ function bandSize(sources, localName) {
475
+ for (const tblPr of sources) {
476
+ const v = intVal(tblPr, localName, 0);
477
+ if (v > 0)
478
+ return v;
479
+ }
480
+ return 1;
481
+ }
482
+ /**
483
+ * The cell's span of table grid columns. A row's `w:gridBefore` skips grid
484
+ * columns before its first cell (§ 17.4.15) and each cell covers
485
+ * `w:gridSpan` columns, so the first physical cell is not always the first
486
+ * column. The grid width is the table's `w:tblGrid`, or the row's own extent
487
+ * when the table has none.
488
+ */
489
+ function cellGridSpan(tbl, tr, tc) {
490
+ const trPr = ownChild(tr, W.trPr);
491
+ let col = intVal(trPr, 'gridBefore', 0);
492
+ let start = -1;
493
+ let end = -1;
494
+ walkTableChildren(tr, 'tc', (cell) => {
495
+ const span = Math.max(1, intVal(ownChild(cell, W.tcPr), 'gridSpan', 1));
496
+ if (cell === tc) {
497
+ start = col;
498
+ end = col + span;
499
+ }
500
+ col += span;
501
+ });
502
+ const grid = ownChild(tbl, W.tblGrid);
503
+ const gridCols = grid ? childElements(grid, 'gridCol').length : 0;
504
+ const gridCount = gridCols > 0 ? gridCols : col + intVal(trPr, 'gridAfter', 0);
505
+ return { start, end, gridCount };
506
+ }
507
+ /**
508
+ * Which conditional types apply to a cell, given the `w:tblLook` in force
509
+ * for its row. Corner types need both of their edges switched on (MS-OI29500
510
+ * note on § 17.7.6). Banding counts rows (grid columns) after the header row
511
+ * (first column) when that is switched on, in groups of
512
+ * `w:tblStyleRowBandSize` (`w:tblStyleColBandSize`). `rowBandsUsed` skips
513
+ * the row-index walk when the style has no row-band conditional.
514
+ */
515
+ function applicableConditionals(pos, look, rowBandsUsed) {
516
+ const out = new Set();
517
+ const isFirstCol = pos.colStart === 0;
518
+ if (look.hBand && rowBandsUsed) {
519
+ const idx = pos.rowIndex() - (look.firstRow ? 1 : 0);
520
+ if (idx >= 0)
521
+ out.add(Math.floor(idx / pos.rowBandSize) % 2 === 0 ? 'band1Horz' : 'band2Horz');
522
+ }
523
+ if (look.vBand) {
524
+ const idx = pos.colStart - (look.firstColumn ? 1 : 0);
525
+ if (idx >= 0)
526
+ out.add(Math.floor(idx / pos.colBandSize) % 2 === 0 ? 'band1Vert' : 'band2Vert');
527
+ }
528
+ if (look.firstColumn && isFirstCol)
529
+ out.add('firstCol');
530
+ if (look.lastColumn && pos.isLastCol)
531
+ out.add('lastCol');
532
+ if (look.firstRow && pos.isFirstRow)
533
+ out.add('firstRow');
534
+ if (look.lastRow && pos.isLastRow)
535
+ out.add('lastRow');
536
+ if (look.firstRow && look.firstColumn && pos.isFirstRow && isFirstCol)
537
+ out.add('nwCell');
538
+ if (look.firstRow && look.lastColumn && pos.isFirstRow && pos.isLastCol)
539
+ out.add('neCell');
540
+ if (look.lastRow && look.firstColumn && pos.isLastRow && isFirstCol)
541
+ out.add('swCell');
542
+ if (look.lastRow && look.lastColumn && pos.isLastRow && pos.isLastCol)
543
+ out.add('seCell');
544
+ return out;
545
+ }
546
+ /**
547
+ * The table-style `w:rPr` layers for one set of applicable conditionals,
548
+ * highest precedence first: the applied conditional types from last to first
549
+ * in {@link CONDITIONAL_ORDER}, then the style's own `w:rPr`. Within each,
550
+ * the derived style precedes its `basedOn` ancestors.
551
+ *
552
+ * @see https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/3359ff85-c423-4b9d-be78-d1cf96f79486
553
+ */
554
+ function tableStyleLayers(chain, applied) {
555
+ const layers = [];
556
+ const types = [...CONDITIONAL_ORDER].filter((t) => applied.has(t)).reverse();
557
+ for (const type of types) {
558
+ for (const style of chain) {
559
+ const rPr = style.conditionalRPrs?.get(type);
560
+ if (rPr)
561
+ layers.push(rPr);
562
+ }
563
+ }
564
+ for (const style of chain)
565
+ if (style.rPr)
566
+ layers.push(style.rPr);
567
+ return layers;
568
+ }
569
+ /**
570
+ * The table-style layers for a run, highest precedence first (see
571
+ * {@link tableStyleLayers}); empty when the run is not in a table or the
572
+ * table has no style.
573
+ *
574
+ * The table style is the table's `w:tblStyle`, or the default table style
575
+ * when it names none (or names one that does not exist). The innermost
576
+ * table governs a run in a nested table. Which conditionals apply is
577
+ * computed from the cell's position and the table's `w:tblLook`. The
578
+ * `w:cnfStyle` elements on paragraphs, rows and cells are not consulted: the
579
+ * standard describes them as an optimization that records the outcome, and
580
+ * the position and `w:tblLook` determine it.
581
+ *
582
+ * @conformance ECMA-376 edition 5, Part 1 § 17.7.2
583
+ * @conformance ECMA-376 edition 5, Part 1 § 17.7.6
584
+ * @see https://github.com/UseJunior/safe-docx/issues/1159
585
+ */
586
+ function tableStyleLayersForRun(run, styles) {
587
+ const tc = nearestAncestor(run, W.tc, W.tbl);
588
+ const tr = tc ? nearestAncestor(tc, W.tr, W.tbl) : null;
589
+ const tbl = tr ? nearestAncestor(tr, W.tbl) : null;
590
+ if (!tc || !tr || !tbl)
591
+ return [];
592
+ // Direct children only: a nested w:tblPrChange holds the previous state.
593
+ const tblPr = ownChild(tbl, W.tblPr);
594
+ const tblStyleEl = tblPr ? ownChild(tblPr, 'tblStyle') : null;
595
+ const named = tblStyleEl ? getWAttr(tblStyleEl, 'val') : null;
596
+ const styleId = named && styles.byId.get(named)?.styleType === 'table' ? named : (styles.defaultTableStyleId ?? null);
597
+ const chain = resolveStyleChain(styles, styleId).filter((st) => st.styleType === 'table');
598
+ if (chain.length === 0)
599
+ return [];
600
+ if (!chain.some((st) => st.conditionalRPrs && st.conditionalRPrs.size > 0)) {
601
+ return tableStyleLayers(chain, new Set());
602
+ }
603
+ // A row's w:tblPrEx/w:tblLook overrides the table's for that row (§ 17.4.54).
604
+ const tblPrEx = ownChild(tr, 'tblPrEx');
605
+ const lookEl = (tblPrEx ? ownChild(tblPrEx, 'tblLook') : null) ?? (tblPr ? ownChild(tblPr, 'tblLook') : null);
606
+ const bandSources = [tblPr, ...chain.map((st) => st.tblPr ?? null)];
607
+ const { start, end, gridCount } = cellGridSpan(tbl, tr, tc);
608
+ const pos = {
609
+ isFirstRow: edgeTableChild(tbl, 'tr', false) === tr,
610
+ isLastRow: edgeTableChild(tbl, 'tr', true) === tr,
611
+ rowIndex: () => tableChildIndex(tbl, 'tr', tr),
612
+ colStart: start,
613
+ isLastCol: end === gridCount,
614
+ rowBandSize: bandSize(bandSources, 'tblStyleRowBandSize'),
615
+ colBandSize: bandSize(bandSources, 'tblStyleColBandSize'),
616
+ };
617
+ const rowBandsUsed = chain.some((st) => st.conditionalRPrs?.has('band1Horz') || st.conditionalRPrs?.has('band2Horz'));
618
+ return tableStyleLayers(chain, applicableConditionals(pos, parseTableLook(lookEl), rowBandsUsed));
619
+ }
294
620
  function parseUnderline(parent) {
295
621
  if (!parent)
296
622
  return null;
@@ -425,8 +751,9 @@ function parseEffectiveHighlightVal(parent) {
425
751
  * the run declares. Ordinary properties are taken from the first layer that
426
752
  * specifies them: direct `w:rPr` on the run, then the `w:rStyle`
427
753
  * character-style `basedOn` chain, then the paragraph mark's `w:rPr` inside
428
- * `pPr`, then the paragraph style's `basedOn` chain, and finally the
429
- * document defaults (`w:docDefaults/w:rPrDefault/w:rPr`).
754
+ * `pPr`, then the paragraph style's `basedOn` chain, then — for a run inside
755
+ * a table — the table style, and finally the document defaults
756
+ * (`w:docDefaults/w:rPrDefault/w:rPr`).
430
757
  *
431
758
  * Each property is resolved independently down the chain — a style that
432
759
  * specifies only color does not mask an ancestor's bold.
@@ -441,14 +768,21 @@ function parseEffectiveHighlightVal(parent) {
441
768
  * Document defaults seed toggle evaluation as an absolute base value rather
442
769
  * than acting as another parity level (see {@link resolveToggleProperty}).
443
770
  *
444
- * Not read: table-style run properties. For a run inside a table, when a
445
- * property is declared nowhere above the document defaults and a table style
446
- * declares a value that would change it (for a toggle: turns it on), the
447
- * result is `null` (unresolved) rather than a guess; when no layer declares it
448
- * at all, the result is the OOXML default (see {@link RunFormatting}). A
449
- * toggle that a direct layer sets absolutely is resolved regardless of table
450
- * styles. Numbering-level `rPr` formats only the list label (`w:lvlText`),
451
- * never the paragraph's runs, so it is out of scope.
771
+ * Table styles (#1159): the table context is read from the run's ancestors —
772
+ * the innermost `w:tbl`, its `w:tblStyle` (or the default table style), the
773
+ * `basedOn` chain, `w:tblLook`, and the cell's row and column. The style's
774
+ * `w:rPr` and every conditional `w:tblStylePr/w:rPr` that applies to the
775
+ * cell merge into one layer between the document defaults and the paragraph
776
+ * style (see {@link tableStyleLayers} for the order). For toggles the
777
+ * merged layer's nearest declaration resets the value rather than toggling
778
+ * it: the standard says a table style toggles like any other style, but Word
779
+ * assigns the declared value (MS-OI29500 note on § 17.7.6), and this follows
780
+ * Word.
781
+ *
782
+ * @see https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/14452bbe-be4d-4dbb-90e6-3d23ae9361bc
783
+ *
784
+ * Numbering-level `rPr` formats only the list label (`w:lvlText`), never the
785
+ * paragraph's runs, so it is out of scope.
452
786
  *
453
787
  * Part of docx-core's public surface (see `src/index.ts`) so external
454
788
  * diagnostics — `scripts/check_docx_formatting_loss.mjs` today, the planned
@@ -463,11 +797,14 @@ function parseEffectiveHighlightVal(parent) {
463
797
  * @param params.theme the model produced by {@link parseThemeXml}; when
464
798
  * omitted, direct font/color fallbacks retain their previous behavior
465
799
  *
800
+ * @conformance ECMA-376 edition 5, Part 1 § 17.7.2
466
801
  * @conformance ECMA-376 edition 5, Part 1 § 17.7.3
467
802
  * @conformance ECMA-376 edition 5, Part 1 § 17.7.5.1
803
+ * @conformance ECMA-376 edition 5, Part 1 § 17.7.6
468
804
  * @see https://github.com/UseJunior/safe-docx/issues/737
469
805
  * @see https://github.com/UseJunior/safe-docx/issues/752
470
806
  * @see https://github.com/UseJunior/safe-docx/issues/753
807
+ * @see https://github.com/UseJunior/safe-docx/issues/1159
471
808
  */
472
809
  export function extractEffectiveRunFormatting(params) {
473
810
  const { run, paragraphPPr, paragraphStyleId, styles, theme = null } = params;
@@ -480,11 +817,12 @@ export function extractEffectiveRunFormatting(params) {
480
817
  const rStyleChain = resolveStyleChain(styles, rStyleId);
481
818
  const paragraphStyleChain = resolveStyleChain(styles, paragraphStyleId);
482
819
  // Priority: direct rPr → rStyle chain rPrs → paragraph mark rPr → paragraph
483
- // style chain rPrs → document defaults. Each property resolves
484
- // independently down this list: a chain member that specifies only color
485
- // must not mask an ancestor's bold, so the sources are the individual rPr
486
- // containers, never "the first chain member that has an rPr" (peer review
487
- // on #684; extractStyleRunFormatting above already resolved per property).
820
+ // style chain rPrs → table style (when in a table) → document defaults.
821
+ // Each property resolves independently down this list: a chain member that
822
+ // specifies only color must not mask an ancestor's bold, so the sources are
823
+ // the individual rPr containers, never "the first chain member that has an
824
+ // rPr" (peer review on #684; extractStyleRunFormatting above already
825
+ // resolved per property).
488
826
  const sourcesAboveDefaults = [
489
827
  rPr,
490
828
  ...rStyleChain.map((s) => s.rPr),
@@ -492,48 +830,33 @@ export function extractEffectiveRunFormatting(params) {
492
830
  ...paragraphStyleChain.map((s) => s.rPr),
493
831
  ];
494
832
  const docDefaultsRPr = styles.docDefaultsRPr ?? null;
495
- // Apply from the document defaults through the least specific style
496
- // ancestor to direct run formatting. Paragraph-mark rPr is direct
497
- // formatting at its hierarchy level; a character style can still contribute
498
- // above it before the run's own rPr supplies the final absolute override.
833
+ const tableLayers = tableStyleLayersForRun(run, styles);
834
+ // Apply from the document defaults through the table style and the least
835
+ // specific paragraph-style ancestor to direct run formatting.
836
+ // Paragraph-mark rPr is direct formatting at its hierarchy level; a
837
+ // character style can still contribute above it before the run's own rPr
838
+ // supplies the final absolute override.
499
839
  const toggleSteps = [
500
840
  { rPr: docDefaultsRPr, kind: 'default' },
841
+ {
842
+ declare: (tagLocal) => firstNonNull(tableLayers.map((layer) => parseBoolProp(layer, tagLocal))),
843
+ kind: 'table',
844
+ },
501
845
  ...[...paragraphStyleChain].reverse().map((style) => ({ rPr: style.rPr, kind: 'style' })),
502
846
  { rPr: pRPr, kind: 'direct' },
503
847
  ...[...rStyleChain].reverse().map((style) => ({ rPr: style.rPr, kind: 'style' })),
504
848
  { rPr, kind: 'direct' },
505
849
  ];
506
- // Table styles are the one layer this resolver does not read. They sit
507
- // between the document defaults and the paragraph style, so for a property
508
- // declared nowhere above the document defaults, a table-style declaration
509
- // that differs from the base below it makes the property unresolved. A
510
- // declaration that restates that base cannot change the outcome.
511
- const tableLayers = styles.tableStyleRPrs && styles.tableStyleRPrs.length > 0 && isInsideTable(run)
512
- ? styles.tableStyleRPrs
513
- : [];
514
- const toggle = (tagLocal) => resolveToggleProperty(toggleSteps, tagLocal, tableLayers.some((layer) => parseBoolProp(layer, tagLocal) === true));
850
+ const toggle = (tagLocal) => resolveToggleProperty(toggleSteps, tagLocal);
851
+ const sources = [...sourcesAboveDefaults, ...tableLayers];
515
852
  /**
516
- * Nearest declaration above the document defaults; otherwise the document
517
- * default, otherwise `ooxmlDefault` (`null` for a property with no OOXML
518
- * default), unless an unread table style could change that base.
853
+ * Nearest declaration above the document defaults (table style
854
+ * included); otherwise the document default, otherwise `ooxmlDefault`
855
+ * (`null` for a property with no OOXML default).
519
856
  */
520
- // Hex colours and font names compare case-insensitively: `abcdef` restates
521
- // `ABCDEF`.
522
- const sameValue = (a, b) => typeof a === 'string' && typeof b === 'string' ? a.toUpperCase() === b.toUpperCase() : a === b;
523
857
  const resolveLayered = (parse, ooxmlDefault) => {
524
- const above = firstNonNull(sourcesAboveDefaults.map(parse));
525
- if (above === DECLARED_UNRESOLVED)
526
- return null;
527
- if (above !== null)
528
- return above;
529
- const base = parse(docDefaultsRPr) ?? ooxmlDefault;
530
- if (tableLayers.some((layer) => {
531
- const declared = parse(layer);
532
- return declared !== null && !sameValue(declared, base);
533
- })) {
534
- return null;
535
- }
536
- return base === DECLARED_UNRESOLVED ? null : base;
858
+ const value = firstNonNull(sources.map(parse)) ?? parse(docDefaultsRPr) ?? ooxmlDefault;
859
+ return value === DECLARED_UNRESOLVED ? null : value;
537
860
  };
538
861
  return {
539
862
  bold: toggle(W.b),