From f005d9e9a2f45540266902dab5e3b1bd32963b05 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Wed, 12 Aug 2026 13:06:42 +0200 Subject: [PATCH 01/21] fix: adjust layout-calculating heuristics --- .../heuristic-table-plugin/src/HTMLTable.tsx | 5 +- .../heuristic-table-plugin/src/TableLayout.ts | 4 +- .../src/helpers/TCellConstraintsComputer.ts | 150 ++++++++++++++++-- .../TCellConstraintsComputer.test.ts | 121 ++++++++++++++ .../src/helpers/__tests__/TableLayout.test.ts | 89 +++++++++++ .../__tests__/computeColumnWidths.test.ts | 122 ++++++++++++++ .../__tests__/fillTableDisplay.test.ts | 83 +++++++++- .../__tests__/reduceColumnConstraints.test.ts | 33 ++++ .../__tests__/relaxHeightConstraint.test.ts | 39 +++++ .../src/helpers/computeColumnWidths.ts | 64 +++++--- .../src/helpers/fillTableDisplay.ts | 78 +++++++-- .../src/helpers/reduceColumnConstraints.ts | 33 ++-- .../src/helpers/relaxHeightConstraint.ts | 33 ++++ .../src/shared-types.ts | 30 +++- .../src/useHtmlTableCellProps.ts | 20 ++- 15 files changed, 821 insertions(+), 83 deletions(-) create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/relaxHeightConstraint.ts diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index 05a703f..e44d5f0 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -3,6 +3,7 @@ import { ScrollView, View } from 'react-native'; import TreeRenderer from './TreeRenderer'; import { HTMLTableProps } from './shared-types'; import { getHorizontalSpacing } from './helpers/measure'; +import relaxHeightConstraint from './helpers/relaxHeightConstraint'; function Container({ children, @@ -46,7 +47,9 @@ const HTMLTable = memo(function HTMLTable({ (max, 0) ); +const PERCENTAGE_REGEX = /^(\d*\.?\d+)%$/; +const UNITLESS_REGEX = /^(\d*\.?\d+)$/; + +/** + * Resolve a CSS length coming from `nativeBlockRet` to pixels. + * + * @remarks + * The CSS processor hands us absolute lengths already reduced to numbers, but + * leaves percentages as strings such as `"50%"` — those resolve against the + * table's containing block, which is `contentWidth` here. Keywords (`auto`, + * `min-content`, …) and any value we cannot resolve yield `null`, meaning + * "unconstrained", exactly as an `auto` width would. + */ +function resolveCssSize(value: unknown, contentWidth: number): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) && value >= 0 ? value : null; + } + if (typeof value === 'string') { + const percentage = PERCENTAGE_REGEX.exec(value.trim()); + if (percentage) { + return (contentWidth * Number(percentage[1])) / 100; + } + } + return null; +} + +/** + * Resolve an HTML presentational `width` attribute to pixels. + * + * @remarks + * Unlike CSS, the attribute takes a bare number of pixels (`width="200"`) as + * well as a percentage (`width="50%"`). It is a presentational hint of the + * lowest priority, so any CSS `width` supersedes it. + */ +function resolveAttributeSize( + value: unknown, + contentWidth: number +): number | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + const percentage = PERCENTAGE_REGEX.exec(trimmed); + if (percentage) { + return (contentWidth * Number(percentage[1])) / 100; + } + const unitless = UNITLESS_REGEX.exec(trimmed); + return unitless ? Number(unitless[1]) : null; +} + +/** + * Apply the CSS clamping order to a width: `min-width` beats `max-width`, which + * beats `width` ({@link https://www.w3.org/TR/CSS21/visudet.html#min-max-widths | CSS 2.1 §10.4}). + */ +function clampWidth( + width: number, + minWidth: number | null, + maxWidth: number | null +): number { + let used = width; + if (maxWidth !== null) { + used = Math.min(used, maxWidth); + } + if (minWidth !== null) { + used = Math.max(used, minWidth); + } + return used; +} + + export default class TCellConstraintsComputer { private baseFontCoeff: number; private fallbackFontSize: number; + private contentWidth: number; private fontWeightCoeffs: Record = { '100': 0.8, @@ -73,13 +144,20 @@ export default class TCellConstraintsComputer { constructor({ baseFontCoeff, - fallbackFontSize + fallbackFontSize, + contentWidth }: { baseFontCoeff?: number; fallbackFontSize?: number; + /** + * The width of the table's containing block, against which percentage + * widths are resolved. + */ + contentWidth?: number; }) { this.baseFontCoeff = baseFontCoeff ?? 0.65; this.fallbackFontSize = fallbackFontSize ?? 14; + this.contentWidth = contentWidth ?? 0; } private getContentDensity = pipe( @@ -118,22 +196,44 @@ export default class TCellConstraintsComputer { }); } else { if (tnode.type === 'block') { - const blockStyle = tnode.styles.nativeBlockRet; - const width = - typeof blockStyle.width === 'number' - ? blockStyle.width - : typeof blockStyle.minWidth === 'number' - ? blockStyle.minWidth - : 0; - const margins = getHorizontalMargins(tnode.styles.nativeBlockRet); - stats.blockWidth = Math.max(stats.blockWidth, width + margins); + const width = this.resolveBlockWidth(tnode); + if (width !== null) { + const margins = getHorizontalMargins(tnode.styles.nativeBlockRet); + stats.blockWidth = Math.max(stats.blockWidth, width + margins); + } } tnode.children.forEach((n) => this.assembleCellStats(n, stats)); } return stats; } - private computeTextConstraints(chunks: TextChunkStats[]): TCellConstraints { + /** + * The width a block imposes on the cell holding it, or `null` when it + * imposes none. + * + * @remarks + * A specified `width` is a preference, but `min-width` and `max-width` clamp + * it in that order ({@link https://www.w3.org/TR/CSS21/visudet.html#min-max-widths | CSS 2.1 §10.4}), + * so a `min-width` larger than `max-width` wins — matching a browser. When no + * `width` is given, `min-width` alone still imposes a floor. The HTML + * presentational `width` attribute is consulted last, as befits a hint of the + * lowest priority. + */ + private resolveBlockWidth(tnode: TNode): number | null { + const blockStyle = tnode.styles.nativeBlockRet; + const minWidth = resolveCssSize(blockStyle.minWidth, this.contentWidth); + const maxWidth = resolveCssSize(blockStyle.maxWidth, this.contentWidth); + const cssWidth = resolveCssSize(blockStyle.width, this.contentWidth); + const width = + cssWidth ?? + resolveAttributeSize(tnode.attributes.width, this.contentWidth); + if (width === null && minWidth === null) { + return null; + } + return clampWidth(width ?? minWidth ?? 0, minWidth, maxWidth); + } + + private computeTextConstraints(chunks: TextChunkStats[]): TConstraintsBase { const minWidth = this.geTextMinWidth(chunks); const contentDensity = this.getContentDensity(chunks); return { @@ -146,9 +246,31 @@ export default class TCellConstraintsComputer { const stats = this.assembleCellStats(tnode); const blockWidth = stats.blockWidth; const textConstrains = this.computeTextConstraints(stats.textStats); + // A `max-width` on the cell itself caps the whole cell box. A descendant's + // `max-width` must not, since it only bounds that descendant. + const cellMaxWidth = resolveCssSize( + tnode.styles.nativeBlockRet.maxWidth, + this.contentWidth + ); + // Per CSS 2.1 §17.5.2.2, "if the specified 'width' (W) of the cell is + // greater than MCW, W is the minimum cell width", and the maximum cell + // width is likewise raised by the column 'width'. So an explicit width + // lifts *both* bounds — never just one, or the cell would end up + // narrower than the width it asked for. + const minWidth = + Math.max(blockWidth, textConstrains.minWidth) + stats.horizontalSpace; + const maxWidth = + Math.max(blockWidth, textConstrains.contentDensity) + + stats.horizontalSpace; return { - minWidth: - Math.max(blockWidth, textConstrains.minWidth) + stats.horizontalSpace, + minWidth, + // `max-width` caps the width the cell would *like*, but never takes it + // below the width it needs to hold its longest word: min-content is a + // floor no browser crosses. + maxWidth: + cellMaxWidth === null + ? maxWidth + : Math.max(minWidth, Math.min(maxWidth, cellMaxWidth)), contentDensity: textConstrains.contentDensity }; } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts new file mode 100644 index 0000000..833c20c --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -0,0 +1,121 @@ +import { TNode } from '@native-html/render'; +import TCellConstraintsComputer from '../TCellConstraintsComputer'; +import { TCellConstraints } from '../../shared-types'; +import { createTableTNode } from './utils'; + +function findFirstCell(tnode: TNode): TNode | null { + if (tnode.tagName === 'td' || tnode.tagName === 'th') { + return tnode; + } + for (const child of tnode.children) { + const found = findFirstCell(child); + if (found) { + return found; + } + } + return null; +} + +function constraintsFor(cellMarkup: string, contentWidth = 400): TCellConstraints { + const table = createTableTNode(`${cellMarkup}
`); + const cell = findFirstCell(table); + expect(cell).not.toBeNull(); + return new TCellConstraintsComputer({ contentWidth }).computeCellConstraints( + cell as TNode + ); +} + +describe('TCellConstraintsComputer', () => { + describe('width resolution', () => { + it('should resolve a percentage width against the containing block', () => { + // 50% of a 400px containing block, which a browser resolves against the + // table — not discarded for want of being a number. + const { minWidth } = constraintsFor('a'); + expect(minWidth).toBeGreaterThanOrEqual(200); + expect(minWidth).toBeLessThan(220); + }); + + it('should honour an absolute width', () => { + const { minWidth } = constraintsFor('a'); + expect(minWidth).toBeGreaterThanOrEqual(200); + expect(minWidth).toBeLessThan(220); + }); + + it('should read the presentational width attribute', () => { + const { minWidth } = constraintsFor('a'); + expect(minWidth).toBeGreaterThanOrEqual(200); + expect(minWidth).toBeLessThan(220); + }); + + it('should let a CSS width supersede the presentational attribute', () => { + // The attribute is a hint of the lowest priority. + const { minWidth } = constraintsFor( + 'a' + ); + expect(minWidth).toBeGreaterThanOrEqual(100); + expect(minWidth).toBeLessThan(120); + }); + + it('should ignore a width it cannot resolve', () => { + const { minWidth } = constraintsFor('a'); + expect(minWidth).toBeLessThan(50); + }); + }); + + describe('CSS clamping order', () => { + it('should raise a width up to min-width', () => { + const { minWidth } = constraintsFor( + 'a' + ); + expect(minWidth).toBeGreaterThanOrEqual(300); + }); + + it('should cut a width down to max-width', () => { + const { minWidth } = constraintsFor( + 'a' + ); + expect(minWidth).toBeGreaterThanOrEqual(100); + expect(minWidth).toBeLessThan(150); + }); + + it('should let min-width win over a smaller max-width', () => { + // CSS 2.1 §10.4: the minimum is applied last, so it wins the conflict. + const { minWidth } = constraintsFor( + 'a' + ); + expect(minWidth).toBeGreaterThanOrEqual(200); + }); + + it('should apply min-width on its own, without a width', () => { + const { minWidth } = constraintsFor('a'); + expect(minWidth).toBeGreaterThanOrEqual(250); + }); + }); + + describe('maximum cell width', () => { + it('should cap the maximum width at max-width', () => { + const { maxWidth } = constraintsFor( + `${'lorem ipsum '.repeat(20)}` + ); + expect(maxWidth).toBeLessThanOrEqual(100); + }); + + it('should never report a maximum below the minimum', () => { + // A cap tighter than the longest word must not drive the cell below the + // width it needs to hold that word. + const { minWidth, maxWidth } = constraintsFor( + 'antidisestablishmentarianism' + ); + expect(maxWidth).toBeGreaterThanOrEqual(minWidth); + }); + + it('should keep an explicitly sized block from collapsing the cell', () => { + // A cell holding only an image has no text, so its maximum has to come + // from the block width or the column would vanish. + const { maxWidth } = constraintsFor( + '' + ); + expect(maxWidth).toBeGreaterThanOrEqual(120); + }); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts new file mode 100644 index 0000000..85e4230 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts @@ -0,0 +1,89 @@ +import TableLayout from '../../TableLayout'; +import { Settings } from '../../shared-types'; +import { createTableTNode } from './utils'; + +function layoutFor(html: string, settings: Settings): TableLayout { + return new TableLayout(createTableTNode(html), settings); +} + +describe('TableLayout', () => { + it('should honour an explicit cell width end to end', () => { + // The column was previously clamped against a maximum derived from text + // alone, which ignored the declared width and collapsed this column to the + // width of the word "Hi". + const { columnWidths } = layoutFor( + ` + +
HiHi
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeGreaterThanOrEqual(200); + }); + + it('should keep a column holding only an image', () => { + // An image contributes no text, so a text-derived maximum of zero used to + // clamp this column away entirely. + const { columnWidths } = layoutFor( + ` + +
Hi
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeGreaterThanOrEqual(120); + }); + + it('should fill the container when forceStretch is set and columns match', () => { + const { totalWidth } = layoutFor( + ` + +
AABB
`, + { contentWidth: 400, forceStretch: true } + ); + expect(totalWidth).toBeCloseTo(400); + }); + + it('should give every column a share of the surplus', () => { + // The narrowest column used to be pinned at its minimum, because the + // weights were taken relative to the least dense column. + const { columnWidths } = layoutFor( + ` + + + + + +
1a somewhat longer cell of textan even longer cell of text than the one before it
`, + { contentWidth: 600, forceStretch: false } + ); + const [first, second, third] = columnWidths as [number, number, number]; + expect(first).toBeGreaterThan(0); + expect(second).toBeGreaterThan(first); + expect(third).toBeGreaterThan(second); + }); + + it('should never exceed the container width when it fits', () => { + const { totalWidth } = layoutFor( + ` + + +
alphabetagamma
deltaepsilonzeta
`, + { contentWidth: 500, forceStretch: false } + ); + expect(totalWidth).toBeLessThanOrEqual(500); + }); + + it('should place a cell after a rowspan+colspan rectangle end to end', () => { + const { display } = layoutFor( + ` + + +
AB
C
`, + { contentWidth: 400, forceStretch: false } + ); + expect(display.cells).toMatchObject([ + { x: 0, y: 0, lenX: 2, lenY: 2 }, + { x: 2, y: 0 }, + { x: 2, y: 1 } + ]); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts new file mode 100644 index 0000000..4d4f1db --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts @@ -0,0 +1,122 @@ +import computeColumnWidths from '../computeColumnWidths'; +import { createEmptyDisplay } from '../fillTableDisplay'; +import { Display, DisplayCell, TCellConstraints } from '../../shared-types'; + +function makeDisplay( + cells: Array & { constraints: TCellConstraints }>, + settings: { contentWidth: number; forceStretch?: boolean } +): Display { + const display = createEmptyDisplay(settings); + display.cells = cells.map((cell) => ({ + lenX: 1, + lenY: 1, + tnode: null as never, + ...cell + })); + return display; +} + +describe('computeColumnWidths', () => { + it('should never shrink a column below its minimum width, even when its maximum width is smaller', () => { + // An icon column: a single wide glyph (`width: 40px` plus 11px of padding + // and borders) whose one character makes for a very low content density. + // CSS 2.1 §17.5.2.2 raises both the column minimum and maximum by the + // column `width`, so 51px is a floor the shrink-to-fit pass cannot cross. + const widths = computeColumnWidths( + makeDisplay( + [ + { + x: 0, + y: 0, + constraints: { minWidth: 51, maxWidth: 51, contentDensity: 30.42 } + }, + { + x: 1, + y: 0, + constraints: { + minWidth: 141.13, + maxWidth: 896.35, + contentDensity: 896.35 + } + } + ], + { contentWidth: 400, forceStretch: false } + ) + ); + expect(widths[0]).toBe(51); + expect(widths[1]).toBe(349); + }); + + it('should never deal a column more width than it can use', () => { + // The surplus is shared over how much room each column has left to grow, + // so no column is ever dealt more than its maximum in the first place. + const constraints = [ + { + x: 0, + y: 0, + constraints: { minWidth: 10, maxWidth: 30, contentDensity: 30 } + }, + { + x: 1, + y: 0, + constraints: { minWidth: 10, maxWidth: 40, contentDensity: 100 } + }, + { + x: 2, + y: 0, + constraints: { minWidth: 10, maxWidth: 400, contentDensity: 400 } + } + ]; + const widths = computeColumnWidths( + makeDisplay(constraints, { contentWidth: 400, forceStretch: false }) + ); + widths.forEach((width, i) => { + expect(width).toBeLessThanOrEqual(constraints[i]!.constraints.maxWidth); + expect(width).toBeGreaterThanOrEqual(constraints[i]!.constraints.minWidth); + }); + // The surplus is fully used: the table fills its container. + expect(widths.reduce((a, b) => a + b, 0)).toBeCloseTo(400); + }); + + it('should shrink to fit when no column can use the whole surplus', () => { + // Every column reaches its maximum and the table stays narrower than the + // container, rather than stretching columns past any useful width. + const widths = computeColumnWidths( + makeDisplay( + [ + { + x: 0, + y: 0, + constraints: { minWidth: 10, maxWidth: 30, contentDensity: 30 } + }, + { + x: 1, + y: 0, + constraints: { minWidth: 10, maxWidth: 40, contentDensity: 100 } + } + ], + { contentWidth: 600, forceStretch: false } + ) + ); + expect(widths).toEqual([30, 40]); + }); + + it('should stretch columns of equal density to fill the container when forceStretch is set', () => { + // Two identical columns have no relative preference between them, but + // `forceStretch` still has to fill the container — historically this case + // distributed nothing at all and left the table hugging its content. + const cell = { + constraints: { minWidth: 20, maxWidth: 20, contentDensity: 20 } + }; + const widths = computeColumnWidths( + makeDisplay( + [ + { x: 0, y: 0, ...cell }, + { x: 1, y: 0, ...cell } + ], + { contentWidth: 400, forceStretch: true } + ) + ); + expect(widths).toEqual([200, 200]); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts index 941d3aa..5950823 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts @@ -68,7 +68,9 @@ describe('fillTableDisplay', () => { `; const tnode = createTableTNode(table); const display = createDisplay(tnode); - expect(display.offsetX).toBe(0); + // `offsetX` is the slot cursor of the row last laid out, so it ends up + // just past that row's final cell. + expect(display.offsetX).toBe(1); expect(display.maxX).toBe(3); expect(display.maxY).toBe(1); expect(display.cells).toMatchObject([ @@ -115,7 +117,7 @@ describe('fillTableDisplay', () => { const display = createDisplay(tnode); expect(display.maxX).toBe(2); expect(display.maxY).toBe(1); - expect(display.offsetX).toBe(0); + expect(display.offsetX).toBe(3); expect(display.occupiedCoordinates).toMatchObject([{ x: 0, y: 1 }]); expect(display.cells).toMatchObject([ { @@ -167,7 +169,7 @@ describe('fillTableDisplay', () => { const display = createDisplay(tnode); expect(display.maxX).toBe(2); expect(display.maxY).toBe(1); - expect(display.offsetX).toBe(0); + expect(display.offsetX).toBe(3); expect(display.occupiedCoordinates).toMatchObject([{ x: 1, y: 1 }]); expect(display.cells).toMatchObject([ { @@ -272,7 +274,7 @@ describe('fillTableDisplay', () => { const display = createDisplay(tnode); expect(display.maxX).toBe(2); expect(display.maxY).toBe(2); - expect(display.offsetX).toBe(0); + expect(display.offsetX).toBe(1); expect(display.cells).toMatchObject([ { lenX: 1, @@ -312,4 +314,77 @@ describe('fillTableDisplay', () => { } ]); }); + it('should skip past every slot claimed by consecutive rowspans', () => { + // Two adjacent spanning cells block columns 0 and 1 of the second row, so + // `D` belongs in column 2. Counting the blockers in one pass instead of + // walking slot by slot would land it on column 1, on top of `B`. + const table = ` + + + + + + + + + +
ABC
D
+ `; + const display = createDisplay(createTableTNode(table)); + expect(display.cells).toMatchObject([ + { lenX: 1, lenY: 2, x: 0, y: 0 }, + { lenX: 1, lenY: 2, x: 1, y: 0 }, + { lenX: 1, lenY: 1, x: 2, y: 0 }, + { lenX: 1, lenY: 1, x: 2, y: 1 } + ]); + }); + it('should block every column a cell spans in the rows below it', () => { + // `A` covers a 2x2 rectangle, so `C` starts at column 2 — not column 1, + // which is still inside `A`. + const table = ` + + + + + + + + +
AB
C
+ `; + const display = createDisplay(createTableTNode(table)); + expect(display.cells).toMatchObject([ + { lenX: 2, lenY: 2, x: 0, y: 0 }, + { lenX: 1, lenY: 1, x: 2, y: 0 }, + { lenX: 1, lenY: 1, x: 2, y: 1 } + ]); + }); + it.each([ + ['0', 1], + ['-2', 1], + ['', 1], + ['abc', 1], + ['2.5', 2], + ['3', 3] + ])('should clamp colspan="%s" to a valid span of %i', (colspan, expected) => { + const table = `
A
`; + const display = createDisplay(createTableTNode(table)); + expect(display.cells[0]).toMatchObject({ lenX: expected, x: 0 }); + }); + it('should clamp an invalid rowspan rather than span nothing', () => { + // `rowspan="0"` means "to the end of the row group" in HTML; row groups + // are not modelled here, so it must at least not corrupt the grid. + const table = ` + + + +
AB
C
+ `; + const display = createDisplay(createTableTNode(table)); + expect(display.cells).toMatchObject([ + { lenX: 1, lenY: 1, x: 0, y: 0 }, + { lenX: 1, lenY: 1, x: 1, y: 0 }, + { lenX: 1, lenY: 1, x: 0, y: 1 } + ]); + }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts index 39a426f..608500e 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts @@ -11,6 +11,7 @@ describe('getColumnConstraints', () => { y: 0, constraints: { contentDensity: 3, + maxWidth: 3, minWidth: 2 } }, @@ -21,6 +22,7 @@ describe('getColumnConstraints', () => { y: 0, constraints: { contentDensity: 4, + maxWidth: 4, minWidth: 3 } }, @@ -31,6 +33,7 @@ describe('getColumnConstraints', () => { y: 1, constraints: { contentDensity: 3, + maxWidth: 3, minWidth: 1 } }, @@ -41,6 +44,7 @@ describe('getColumnConstraints', () => { y: 1, constraints: { contentDensity: 2, + maxWidth: 2, minWidth: 1 } } @@ -68,6 +72,7 @@ describe('getColumnConstraints', () => { y: 0, constraints: { contentDensity: 9, + maxWidth: 9, minWidth: 3 } }, @@ -78,6 +83,7 @@ describe('getColumnConstraints', () => { y: 1, constraints: { contentDensity: 4, + maxWidth: 4, minWidth: 2 } } @@ -100,4 +106,31 @@ describe('getColumnConstraints', () => { } ]); }); + it('should keep a slot for a column no cell occupies', () => { + // Callers look constraints up by a cell's absolute `x`, so an unoccupied + // column has to keep its place: compacting it away would hand every later + // column the width of its neighbour. + expect( + reduceColumnConstraints([ + { + lenX: 1, + lenY: 1, + x: 0, + y: 0, + constraints: { contentDensity: 3, maxWidth: 3, minWidth: 2 } + }, + { + lenX: 1, + lenY: 1, + x: 2, + y: 0, + constraints: { contentDensity: 5, maxWidth: 5, minWidth: 4 } + } + ]) + ).toEqual([ + { contentDensity: 3, spread: 3, minWidth: 2 }, + { contentDensity: 0, spread: 0, minWidth: 0 }, + { contentDensity: 5, spread: 5, minWidth: 4 } + ]); + }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts new file mode 100644 index 0000000..f863306 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts @@ -0,0 +1,39 @@ +import relaxHeightConstraint from '../relaxHeightConstraint'; + +describe('relaxHeightConstraint', () => { + it('should translate an explicit height to a minimum height', () => { + expect(relaxHeightConstraint({ height: 48 })).toEqual({ minHeight: 48 }); + }); + it('should preserve unrelated styles', () => { + expect( + relaxHeightConstraint({ height: 48, backgroundColor: 'red' }) + ).toEqual({ + minHeight: 48, + backgroundColor: 'red' + }); + }); + it('should leave styles without an explicit height untouched', () => { + expect(relaxHeightConstraint({ minHeight: 10, maxHeight: 20 })).toEqual({ + minHeight: 10, + maxHeight: 20 + }); + }); + it('should retain the greatest of height and minHeight', () => { + expect(relaxHeightConstraint({ height: 48, minHeight: 10 })).toEqual({ + minHeight: 48 + }); + expect(relaxHeightConstraint({ height: 10, minHeight: 48 })).toEqual({ + minHeight: 48 + }); + }); + it('should favor an explicit minHeight over an incomparable height', () => { + expect(relaxHeightConstraint({ height: '50%', minHeight: 48 })).toEqual({ + minHeight: 48 + }); + }); + it('should not enforce a percentage height either', () => { + expect(relaxHeightConstraint({ height: '50%' })).toEqual({ + minHeight: '50%' + }); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts index b316322..87a6965 100644 --- a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts @@ -9,38 +9,60 @@ function mapSpreads(constraints: TColumnConstraints[]): number[] { return constraints.map((c) => c.spread); } -// Normalize content densities so the minimum column is zero-referenced, -// then weight them so they sum to 1 (used to distribute extra width). -function mapWeightedColumnCoeffs( - columnConstraints: TColumnConstraints[] -): number[] { - const densities = columnConstraints.map((c) => c.contentDensity); - const minDensity = densities.reduce( - (acc, x) => Math.min(acc, x), - Infinity - ); - const normalized = densities.map((x) => x - minDensity); - const total = normalized.reduce((acc, x) => acc + x, 0); - return normalized.map((x) => (total === 0 ? 0 : x / total)); +function sumOf(values: number[]): number { + return values.reduce((acc, x) => acc + x, 0); +} + +/** + * Share `total` across `weights`, proportionally. Falls back to an even share + * when every weight is zero, so that no space is ever silently dropped. + */ +function distribute(total: number, weights: number[]): number[] { + if (weights.length === 0) { + return []; + } + const totalWeight = sumOf(weights); + if (totalWeight === 0) { + return weights.map(() => total / weights.length); + } + return weights.map((weight) => (total * weight) / totalWeight); } export default function computeColumnWidths(display: Display): number[] { const contentWidth = display.contentWidth; - const shouldClampWidth = !display.forceStretch; + const shouldStretch = !!display.forceStretch; const columnConstraints = reduceColumnConstraints(display.cells); + if (columnConstraints.length === 0) { + return []; + } const minWidths = mapMinWidths(columnConstraints); const spreads = mapSpreads(columnConstraints); - const sumOfMinWidths = minWidths.reduce((a, b) => a + b, 0); + const sumOfMinWidths = sumOf(minWidths); if (contentWidth < sumOfMinWidths) { + // The table cannot fit: no column may go below the width it needs to hold + // its longest word, so the table overflows and `HTMLTable` scrolls it. return minWidths; } const widthToAssign = contentWidth - sumOfMinWidths; - const weightedCoeffs = mapWeightedColumnCoeffs(columnConstraints); - const rawWidths = minWidths.map( - (min, i) => min + weightedCoeffs[i]! * widthToAssign + // Each column may usefully grow from its minimum up to its maximum, and no + // further. CSS 2.1 §17.5.2.2 shares the surplus over that headroom, so every + // column that can still benefit gets a proportional share — including the + // least demanding one, which must not be starved. + const headrooms = spreads.map((spread, i) => + Math.max(0, spread - (minWidths[i] ?? 0)) ); - if (shouldClampWidth) { - return rawWidths.map((w, i) => Math.min(w, spreads[i] ?? Infinity)); + const totalHeadroom = sumOf(headrooms); + if (widthToAssign < totalHeadroom) { + const shares = distribute(widthToAssign, headrooms); + return minWidths.map((min, i) => min + (shares[i] ?? 0)); + } + // Every column can reach its maximum width. Shrink-to-fit leaves the table + // narrower than its container; `forceStretch` instead spreads the remainder + // over the columns, in proportion to how much width each one can put to use. + if (!shouldStretch) { + return spreads; } - return rawWidths; + const leftover = widthToAssign - totalHeadroom; + const shares = distribute(leftover, spreads); + return spreads.map((spread, i) => spread + (shares[i] ?? 0)); } diff --git a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts index 58785e0..bc4de09 100644 --- a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts +++ b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts @@ -13,13 +13,51 @@ export function createEmptyDisplay(config: Settings): Display { }; } -function computeOffsetX(display: Display, startX: number, startY: number) { - return display.occupiedCoordinates.reduce((prev, coordinates) => { - if (coordinates.x <= startX && coordinates.y === startY) { - return prev + 1; - } - return prev; - }, 0); +const MAX_COLSPAN = 1000; +const MAX_ROWSPAN = 65534; + +/** + * Parse a `colspan` / `rowspan` attribute the way HTML requires. + * + * @remarks + * The attribute is a non-negative integer, clamped to a maximum; anything + * invalid — a missing value, a negative, a fraction, `0`, or plain nonsense — + * falls back to `1`. Letting a raw `Number()` through instead lets `0` and + * negatives corrupt the grid cursor. + * + * Note that `rowspan="0"` means "span to the end of the row group" in HTML. + * Row groups are not modelled here, so it degrades to `1` rather than + * silently spanning nothing. + */ +function parseSpan(value: unknown, max: number): number { + const parsed = typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(parsed)) { + return 1; + } + return Math.min(Math.max(Math.floor(parsed), 1), max); +} + +function isOccupied(display: Display, x: number, y: number): boolean { + return display.occupiedCoordinates.some( + (coordinates) => coordinates.x === x && coordinates.y === y + ); +} + +/** + * Find the first slot in row `y` at or after `fromX` that no spanning cell has + * already claimed. + * + * @remarks + * The search must advance one slot at a time: counting blockers in a single + * pass can land the cell on another blocked slot, so two cells end up sharing + * one coordinate. + */ +function findFreeSlotX(display: Display, fromX: number, y: number): number { + let x = fromX; + while (isOccupied(display, x, y)) { + x += 1; + } + return x; } export default function fillTableDisplay( @@ -32,14 +70,15 @@ export default function fillTableDisplay( display.offsetX = 0; } if (tnode.tagName === 'th' || tnode.tagName === 'td') { - const rowspan = Number(tnode.attributes.rowspan); - const colspan = Number(tnode.attributes.colspan); - const lenX = Number.isFinite(colspan) ? colspan : 1; - const lenY = Number.isFinite(rowspan) ? rowspan : 1; - const initialStartX = display.offsetX + tnode.nodeIndex; + const lenX = parseSpan(tnode.attributes.colspan, MAX_COLSPAN); + const lenY = parseSpan(tnode.attributes.rowspan, MAX_ROWSPAN); const startY = display.maxY; - const startX = - computeOffsetX(display, initialStartX, display.maxY) + initialStartX; + // `offsetX` is the slot cursor for the current row: cells are laid down + // left to right from wherever the previous one ended, skipping any slot a + // spanning cell from an earlier row has already claimed. Deriving the + // column from `nodeIndex` instead would let a stray non-cell element + // inside the row shift every following cell. + const startX = findFreeSlotX(display, display.offsetX, startY); const constraints = computer.computeCellConstraints(tnode); const cell: DisplayCell = { lenX, @@ -50,13 +89,18 @@ export default function fillTableDisplay( constraints }; display.cells.push(cell); - display.offsetX += lenX - 1; + display.offsetX = startX + lenX; if (lenY > 1) { + // A spanning cell claims the whole rectangle it covers, so a cell that + // is both `colspan` and `rowspan` blocks every column it straddles in + // each of the rows below — not just its first one. for (let y = startY + 1; y < lenY + startY; y++) { - display.occupiedCoordinates.push({ x: startX, y }); + for (let x = startX; x < startX + lenX; x++) { + display.occupiedCoordinates.push({ x, y }); + } } } - display.maxX = Math.max(display.maxX, initialStartX); + display.maxX = Math.max(display.maxX, startX); } else { tnode.children.forEach((child) => fillTableDisplay(child, display, computer) diff --git a/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts b/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts index ab593a9..cd8f6ba 100644 --- a/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts +++ b/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts @@ -7,7 +7,7 @@ import { } from '../shared-types'; function getColumnMetrics(cells: CellProperties[]): TColumnConstraints { - return cells + const column = cells .map((c) => c.constraints) .reduce( (columnConstraints: TColumnConstraints, cellConstraints: TCellConstraints) => ({ @@ -17,13 +17,15 @@ function getColumnMetrics(cells: CellProperties[]): TColumnConstraints { ), contentDensity: columnConstraints.contentDensity + cellConstraints.contentDensity, - spread: Math.max( - columnConstraints.spread, - cellConstraints.contentDensity - ) + spread: Math.max(columnConstraints.spread, cellConstraints.maxWidth) }), { minWidth: 0, spread: 0, contentDensity: 0 } ); + // CSS 2.1 §17.5.2.2 derives the column minimum and maximum from the same + // cells, each floored by the column 'width' — so a maximum below its own + // minimum is not a state the spec can produce. Restate it here so callers + // may clamp against `spread` without starving the column. + return { ...column, spread: Math.max(column.spread, column.minWidth) }; } function splitColspanCells(cell: CellProperties): CellProperties | CellProperties[] { @@ -35,6 +37,7 @@ function splitColspanCells(cell: CellProperties): CellProperties | CellPropertie lenY: cell.lenY, constraints: { minWidth: cell.constraints.minWidth / cell.lenX, + maxWidth: cell.constraints.maxWidth / cell.lenX, contentDensity: cell.constraints.contentDensity / cell.lenX }, x: cell.x + i, @@ -50,11 +53,21 @@ export default function reduceColumnConstraints( cells: CellProperties[] ): TColumnConstraints[] { const flatCells = flatten(cells.map(splitColspanCells)) as CellProperties[]; - const grouped: Record = {}; + if (flatCells.length === 0) { + return []; + } + const grouped: CellProperties[][] = []; + let lastColumn = 0; for (const cell of flatCells) { - const key = String(cell.x); - if (!grouped[key]) grouped[key] = []; - grouped[key].push(cell); + // Callers index the result by a cell's absolute `x`, so the array has to + // stay dense: a column that no cell occupies must still hold a slot, or + // every column after it would be handed the width of its neighbour. + (grouped[cell.x] ??= []).push(cell); + lastColumn = Math.max(lastColumn, cell.x); + } + const columns: TColumnConstraints[] = []; + for (let x = 0; x <= lastColumn; x++) { + columns[x] = getColumnMetrics(grouped[x] ?? []); } - return Object.values(grouped).map(getColumnMetrics); + return columns; } diff --git a/packages/heuristic-table-plugin/src/helpers/relaxHeightConstraint.ts b/packages/heuristic-table-plugin/src/helpers/relaxHeightConstraint.ts new file mode 100644 index 0000000..ecc3411 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/relaxHeightConstraint.ts @@ -0,0 +1,33 @@ +import type { ViewStyle } from 'react-native'; + +type HeightConstraints = Pick; + +/** + * Fold an explicit `height` into a `minHeight` constraint. + * + * @remarks + * Per {@link https://www.w3.org/TR/CSS21/tables.html#height-layout | CSS 2.1 + * §17.5.3}, the `height` property of `table`, `tr`, `th` and `td` boxes only + * defines a *minimum* height: those boxes always grow to fit their content. + * React Native has no table layout algorithm, so passing that `height` down to + * a `View` would enforce it, and taller content would overflow (or be clipped) + * instead of expanding the cell. `minHeight` conveys the HTML semantic + * faithfully. + * + * @param style - Native styles of a `table`, `tr`, `th` or `td` element. + * + * @returns The same styles, with `height` removed and merged into `minHeight`. + */ +export default function relaxHeightConstraint( + style: T +): Omit { + const { height, ...rest } = style; + if (height == null) { + return rest; + } + const minHeight = + typeof height === 'number' && typeof rest.minHeight === 'number' + ? Math.max(height, rest.minHeight) + : rest.minHeight ?? height; + return { ...rest, minHeight }; +} diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 534c1de..5893b71 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -34,12 +34,18 @@ export interface TConstraintsBase { */ export interface TColumnConstraints extends TConstraintsBase { /** - * The minimum number for the text in one column to hold in one line. + * The width beyond which this column would gain nothing — the *maximum + * column width* of {@link https://www.w3.org/TR/CSS21/tables.html#auto-table-layout | CSS 2.1 §17.5.2.2}. * - * @remarks spread and contentDensity only differ when applied to a - * whole column. Spread width will be the maximum of cell content densities, - * while the column content density will be the sum of the cell content - * densities. + * @remarks + * This is the greatest {@link TCellConstraints.maxWidth} among the cells of + * the column, and is always at least {@link TConstraintsBase.minWidth}: + * per the spec, both bounds are raised by the column `width`, so a maximum + * can never sit below its own minimum. + * + * Note that spread and contentDensity only differ when applied to a whole + * column: the column content density is the *sum* of the cell content + * densities, whereas spread is a maximum. */ spread: number; } @@ -47,8 +53,18 @@ export interface TColumnConstraints extends TConstraintsBase { /** * @public */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface TCellConstraints extends TConstraintsBase {} +export interface TCellConstraints extends TConstraintsBase { + /** + * The width at which this cell would stop benefiting from more space — the + * *maximum cell width* of {@link https://www.w3.org/TR/CSS21/tables.html#auto-table-layout | CSS 2.1 §17.5.2.2}, + * including horizontal spacing. + * + * @remarks + * Like {@link TConstraintsBase.minWidth}, this is raised by an explicit + * `width` on the cell, so it is never below `minWidth`. + */ + maxWidth: number; +} /** * @public diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index 5c9902e..5b4c57f 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -1,5 +1,6 @@ import { TBlock, CustomRendererProps } from '@native-html/render'; import { TableCellPropsFromParent } from './shared-types'; +import relaxHeightConstraint from './helpers/relaxHeightConstraint'; /** * Customize `td` and `th` renderers while reusing default cell renderer logic. @@ -14,15 +15,18 @@ export default function useHtmlTableCellProps({ }: CustomRendererProps): CustomRendererProps { const { config, cell } = propsFromParent as TableCellPropsFromParent; const styleFromConfig = config?.getStyleForCell?.call(null, cell); - let spanStyles = {}; - if (cell.lenY > 1) { - spanStyles = { justifyContent: 'center' }; - } - if (cell.lenX > 1) { - spanStyles = { alignItems: 'center' }; - } + // Vertical and horizontal centering are independent, so a cell that both + // spans rows and spans columns must keep the two: assigning here rather than + // merging would drop the vertical centering of every `rowspan`+`colspan` + // cell. + const spanStyles = { + ...(cell.lenY > 1 ? { justifyContent: 'center' as const } : null), + ...(cell.lenX > 1 ? { alignItems: 'center' as const } : null) + }; const style = { - ...props.style, + // An explicit height on a cell is a minimum height in HTML, so that the + // cell still grows to fit its content. + ...relaxHeightConstraint(props.style), flexGrow: 1, flexShrink: 0, ...spanStyles, From ff5b21e50df5a6cf0533533d180abb826f983508 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Thu, 3 Sep 2026 11:52:58 +0200 Subject: [PATCH 02/21] fix(heuristic-table-plugin): add colgrups and redistribute widths --- packages/heuristic-table-plugin/README.md | 6 +- .../heuristic-table-plugin.colgroupmodel.md | 13 ++ .../docs/heuristic-table-plugin.md | 11 ++ .../etc/heuristic-table-plugin.api.md | 5 + .../src/ColgroupModel.ts | 20 +++ .../heuristic-table-plugin/src/HTMLTable.tsx | 12 +- .../heuristic-table-plugin/src/TableLayout.ts | 21 ++- .../src/helpers/TCellConstraintsComputer.ts | 105 +++---------- .../TCellConstraintsComputer.test.ts | 16 ++ .../src/helpers/__tests__/TableLayout.test.ts | 106 +++++++++++++ .../src/helpers/__tests__/utils.ts | 11 +- .../src/helpers/computeColumnWidths.ts | 144 +++++++++++++++--- .../src/helpers/extractColumnWidths.ts | 143 +++++++++++++++++ .../src/helpers/resolveWidth.ts | 83 ++++++++++ packages/heuristic-table-plugin/src/index.ts | 3 +- 15 files changed, 589 insertions(+), 110 deletions(-) create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.colgroupmodel.md create mode 100644 packages/heuristic-table-plugin/src/ColgroupModel.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/resolveWidth.ts diff --git a/packages/heuristic-table-plugin/README.md b/packages/heuristic-table-plugin/README.md index ee0513e..746c20b 100644 --- a/packages/heuristic-table-plugin/README.md +++ b/packages/heuristic-table-plugin/README.md @@ -58,7 +58,7 @@ yarn add @native-html/heuristic-table-plugin import React from 'react'; import { ScrollView } from 'react-native'; import HTML from '@native-html/render'; -import tableRenderers from '@native-html/heuristic-table-plugin'; +import tableRenderers, {colgroupModel} from '@native-html/heuristic-table-plugin'; const html = ` @@ -78,6 +78,10 @@ const htmlProps = { renderers: { ...tableRenderers }, + customHTMLElementModels: { + // Required for widths declared by and . + colgroup: colgroupModel + }, renderersProps: { table: { // Put the table config here diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.colgroupmodel.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.colgroupmodel.md new file mode 100644 index 0000000..164c033 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.colgroupmodel.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [colgroupModel](./heuristic-table-plugin.colgroupmodel.md) + +## colgroupModel variable + +Element model required for colgroup children to be available to the table layout engine. Col elements remain non-rendering empty nodes. + +**Signature:** + +```typescript +colgroupModel: HTMLElementModel<'colgroup', HTMLContentModel.block> +``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md index 0da7fff..d867b1b 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md @@ -179,6 +179,17 @@ Description +'); + + // The longest unbreakable segment is "Medium-" (7 characters), not the + // full 11-character string. + expect(minWidth).toBeCloseTo(7 * 14 * 0.65); + }); + + it('should retain a non-breaking hyphen in one segment', () => { + const { minWidth } = constraintsFor(''); + + expect(minWidth).toBeCloseTo(11 * 14 * 0.65); + }); + }); + describe('width resolution', () => { it('should resolve a percentage width against the containing block', () => { // 50% of a 400px containing block, which a browser resolves against the diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts index 85e4230..755d798 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts @@ -20,6 +20,112 @@ describe('TableLayout', () => { expect(columnWidths[0]).toBeGreaterThanOrEqual(200); }); + it('should honour percentage widths declared by col elements', () => { + const { columnWidths } = layoutFor( + `
+[colgroupModel](./heuristic-table-plugin.colgroupmodel.md) + + + + +Element model required for colgroup children to be available to the table layout engine. Col elements remain non-rendering empty nodes. + + +
+ [HTMLTable](./heuristic-table-plugin.htmltable.md) diff --git a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md index 5a3eef2..5bd3dc1 100644 --- a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md +++ b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md @@ -6,6 +6,8 @@ import { CustomBlockRenderer } from '@native-html/render'; import { CustomRendererProps } from '@native-html/render'; +import { HTMLContentModel } from '@native-html/render'; +import { HTMLElementModel } from '@native-html/render'; import { PropsFromParent } from '@native-html/render'; import { default as React_2 } from 'react'; import { TBlock } from '@native-html/render'; @@ -24,6 +26,9 @@ export interface CellProperties extends Coordinates { lenY: number; } +// @public +export const colgroupModel: HTMLElementModel<'colgroup', HTMLContentModel.block>; + // @public (undocumented) export interface Coordinates { // (undocumented) diff --git a/packages/heuristic-table-plugin/src/ColgroupModel.ts b/packages/heuristic-table-plugin/src/ColgroupModel.ts new file mode 100644 index 0000000..153a59a --- /dev/null +++ b/packages/heuristic-table-plugin/src/ColgroupModel.ts @@ -0,0 +1,20 @@ +import { + defaultHTMLElementModels, + HTMLContentModel, + HTMLElementModel +} from '@native-html/render'; + +/** + * Element model required for colgroup children to be available to the table + * layout engine. Col elements remain non-rendering empty nodes. + * + * @public + */ +const colgroupModel: HTMLElementModel< + 'colgroup', + HTMLContentModel.block +> = defaultHTMLElementModels.colgroup.extend({ + contentModel: HTMLContentModel.block +}); + +export default colgroupModel; diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index e44d5f0..75221a7 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -5,6 +5,16 @@ import { HTMLTableProps } from './shared-types'; import { getHorizontalSpacing } from './helpers/measure'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; +export function shouldScrollTable( + tableWidth: number, + availableWidth: number +): boolean { + // Browser/WebView scroll metrics are pixel-rounded. Avoid turning harmless + // subpixel overshoots from generated values such as width:100.055% into a + // dedicated native horizontal scroller. + return tableWidth - availableWidth > 1; +} + function Container({ children, tableWidth, @@ -13,7 +23,7 @@ function Container({ tableWidth: number; availableWidth: number; }>) { - const scroll = tableWidth > availableWidth; + const scroll = shouldScrollTable(tableWidth, availableWidth); return scroll ? React.createElement( ScrollView, diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 3ffdbac..265b62f 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -7,6 +7,8 @@ import fillTableDisplay, { } from './helpers/fillTableDisplay'; import TCellConstraintsComputer from './helpers/TCellConstraintsComputer'; import { Display, Settings, TableRoot } from './shared-types'; +import extractColumnWidths from './helpers/extractColumnWidths'; +import { resolveNodeWidth } from './helpers/resolveWidth'; export default class TableLayout { public readonly display: Display; @@ -14,12 +16,25 @@ export default class TableLayout { public readonly totalWidth: number; public readonly renderTree: TableRoot; constructor(tnode: TNode, config: Settings) { + const declaredTableWidth = resolveNodeWidth(tnode, config.contentWidth); + const layoutContentWidth = declaredTableWidth ?? config.contentWidth; + const layoutSettings = { + ...config, + contentWidth: layoutContentWidth, + // A table with a specified width distributes that width over its columns; + // shrink-to-fit only applies when the table width is auto. + forceStretch: config.forceStretch || declaredTableWidth !== null + }; const computer = new TCellConstraintsComputer({ - contentWidth: config.contentWidth + contentWidth: layoutContentWidth }); - this.display = createEmptyDisplay(config); + this.display = createEmptyDisplay(layoutSettings); fillTableDisplay(tnode, this.display, computer); - this.columnWidths = computeColumnWidths(this.display); + const declaredColumnWidths = extractColumnWidths(tnode, layoutContentWidth); + this.columnWidths = computeColumnWidths( + this.display, + declaredColumnWidths + ); this.totalWidth = sum(this.columnWidths); this.renderTree = createRenderTree(this.display, this.columnWidths); } diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index ab38607..9be5548 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -2,11 +2,11 @@ import pipe from 'ramda/src/pipe'; import sum from 'ramda/src/sum'; import map from 'ramda/src/map'; import max from 'ramda/src/max'; -import prop from 'ramda/src/prop'; import reduce from 'ramda/src/reduce'; import { TNode } from '@native-html/render'; import { TCellConstraints, TConstraintsBase } from '../shared-types'; import { getHorizontalMargins, getHorizontalSpacing } from './measure'; +import { resolveCssSize, resolveNodeWidth } from './resolveWidth'; interface TextChunkStats { fontWeightCoeff: number; @@ -48,81 +48,28 @@ function getInitCellStatsForTnode(tnode: TNode): TCellStats { }; } -const getMaxWordSize = pipe( - map(prop('length')), - reduce(max, 0) -); - -const PERCENTAGE_REGEX = /^(\d*\.?\d+)%$/; -const UNITLESS_REGEX = /^(\d*\.?\d+)$/; - -/** - * Resolve a CSS length coming from `nativeBlockRet` to pixels. - * - * @remarks - * The CSS processor hands us absolute lengths already reduced to numbers, but - * leaves percentages as strings such as `"50%"` — those resolve against the - * table's containing block, which is `contentWidth` here. Keywords (`auto`, - * `min-content`, …) and any value we cannot resolve yield `null`, meaning - * "unconstrained", exactly as an `auto` width would. - */ -function resolveCssSize(value: unknown, contentWidth: number): number | null { - if (typeof value === 'number') { - return Number.isFinite(value) && value >= 0 ? value : null; - } - if (typeof value === 'string') { - const percentage = PERCENTAGE_REGEX.exec(value.trim()); - if (percentage) { - return (contentWidth * Number(percentage[1])) / 100; +function getMaxUnbreakableTextLength(text: string): number { + let currentLength = 0; + let maxLength = 0; + for (const character of text) { + if (/\s/u.test(character)) { + currentLength = 0; + continue; + } + currentLength += character.length; + // A line can break after a regular hyphen. Keep the hyphen in the + // preceding segment because it still occupies space at the line end. + // U+2011 NON-BREAKING HYPHEN is deliberately not included. + if (character === '-' || character === '\u2010') { + maxLength = Math.max(maxLength, currentLength); + currentLength = 0; + } else { + maxLength = Math.max(maxLength, currentLength); } } - return null; -} - -/** - * Resolve an HTML presentational `width` attribute to pixels. - * - * @remarks - * Unlike CSS, the attribute takes a bare number of pixels (`width="200"`) as - * well as a percentage (`width="50%"`). It is a presentational hint of the - * lowest priority, so any CSS `width` supersedes it. - */ -function resolveAttributeSize( - value: unknown, - contentWidth: number -): number | null { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - const percentage = PERCENTAGE_REGEX.exec(trimmed); - if (percentage) { - return (contentWidth * Number(percentage[1])) / 100; - } - const unitless = UNITLESS_REGEX.exec(trimmed); - return unitless ? Number(unitless[1]) : null; -} - -/** - * Apply the CSS clamping order to a width: `min-width` beats `max-width`, which - * beats `width` ({@link https://www.w3.org/TR/CSS21/visudet.html#min-max-widths | CSS 2.1 §10.4}). - */ -function clampWidth( - width: number, - minWidth: number | null, - maxWidth: number | null -): number { - let used = width; - if (maxWidth !== null) { - used = Math.min(used, maxWidth); - } - if (minWidth !== null) { - used = Math.max(used, minWidth); - } - return used; + return maxLength; } - export default class TCellConstraintsComputer { private baseFontCoeff: number; private fallbackFontSize: number; @@ -189,7 +136,7 @@ export default class TCellConstraintsComputer { const fontWeightCoeff = this.fontWeightCoeffs[String(fontWeight)] ?? 1; stats.textStats.push({ characters: tnode.data.length, - maxWordLength: getMaxWordSize(tnode.data.split(/\s+/)), + maxWordLength: getMaxUnbreakableTextLength(tnode.data), fontFamilyCoeff: 1, fontSize, fontWeightCoeff @@ -220,17 +167,7 @@ export default class TCellConstraintsComputer { * lowest priority. */ private resolveBlockWidth(tnode: TNode): number | null { - const blockStyle = tnode.styles.nativeBlockRet; - const minWidth = resolveCssSize(blockStyle.minWidth, this.contentWidth); - const maxWidth = resolveCssSize(blockStyle.maxWidth, this.contentWidth); - const cssWidth = resolveCssSize(blockStyle.width, this.contentWidth); - const width = - cssWidth ?? - resolveAttributeSize(tnode.attributes.width, this.contentWidth); - if (width === null && minWidth === null) { - return null; - } - return clampWidth(width ?? minWidth ?? 0, minWidth, maxWidth); + return resolveNodeWidth(tnode, this.contentWidth); } private computeTextConstraints(chunks: TextChunkStats[]): TConstraintsBase { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index 833c20c..8c6411e 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -26,6 +26,22 @@ function constraintsFor(cellMarkup: string, contentWidth = 400): TCellConstraint } describe('TCellConstraintsComputer', () => { + describe('text break opportunities', () => { + it('should allow a line break after a hyphen', () => { + const { minWidth } = constraintsFor('Medium-HighMedium‑High
+ + + + + + + +
ABCD
`, + { contentWidth: 600, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(160); + expect(columnWidths[1]).toBeCloseTo(80); + expect(columnWidths[2]).toBeCloseTo(100); + expect(columnWidths[3]).toBeCloseTo(60); + }); + + it('should resolve column percentages against the declared table width', () => { + const { columnWidths, totalWidth } = layoutFor( + ` + + +
AB
`, + { contentWidth: 600, forceStretch: false } + ); + expect(columnWidths).toEqual([150, 150]); + expect(totalWidth).toBe(300); + }); + + it('should expand col and colgroup span declarations', () => { + const { columnWidths } = layoutFor( + ` + + + + + +
ABC
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths).toEqual([100, 100, 200]); + }); + + it('should expand a colgroup span when it has no col children', () => { + const { columnWidths } = layoutFor( + ` + + +
ABC
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths).toEqual([100, 100, 100]); + }); + + it('should prefer a CSS col width over its HTML width attribute', () => { + const { columnWidths } = layoutFor( + ` + + +
AB
`, + { contentWidth: 300, forceStretch: false } + ); + expect(columnWidths[0]).toBe(100); + }); + + it('should let cell content make a declared column wider', () => { + const { columnWidths } = layoutFor( + ` + + +
averyveryverylongwordB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeGreaterThan(20); + }); + + it('should reconcile percent columns with min-content inside the table width', () => { + const { columnWidths, totalWidth } = layoutFor( + ` + + +
Alongword
`, + { contentWidth: 300, forceStretch: false } + ); + expect(columnWidths[1]).toBeGreaterThan(60); + expect(totalWidth).toBeCloseTo(300); + }); + + it('should cap accumulated intrinsic column percentages at 100%', () => { + const { columnWidths, totalWidth } = layoutFor( + ` + + + + + +
AB
`, + { contentWidth: 300, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(180); + expect(columnWidths[1]).toBeCloseTo(120); + expect(totalWidth).toBeCloseTo(300); + }); + it('should keep a column holding only an image', () => { // An image contributes no text, so a text-derived maximum of zero used to // clamp this column away entirely. diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts index 8907154..9f3bc20 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts @@ -1,5 +1,14 @@ import { TRenderEngine } from '@native-html/transient-render-engine'; -const engine = new TRenderEngine(); +import colgroupModel from '../../ColgroupModel'; + +const engine = new TRenderEngine({ + customizeHTMLModels(defaultModels) { + return { + ...defaultModels, + colgroup: colgroupModel + }; + } +}); export function createTableTNode(html: string) { const ttree = engine.buildTTree(html); diff --git a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts index 87a6965..a12fb1c 100644 --- a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts @@ -1,5 +1,6 @@ import { Display, TColumnConstraints } from '../shared-types'; import reduceColumnConstraints from './reduceColumnConstraints'; +import type { DeclaredColumnWidth } from './extractColumnWidths'; function mapMinWidths(constraints: TColumnConstraints[]): number[] { return constraints.map((c) => c.minWidth); @@ -28,10 +29,84 @@ function distribute(total: number, weights: number[]): number[] { return weights.map((weight) => (total * weight) / totalWeight); } -export default function computeColumnWidths(display: Display): number[] { +function interpolateWidths( + lower: number[], + upper: number[], + targetWidth: number +): number[] { + const lowerTotal = sumOf(lower); + const upperTotal = sumOf(upper); + if (upperTotal <= lowerTotal) { + return lower; + } + const progress = Math.max( + 0, + Math.min(1, (targetWidth - lowerTotal) / (upperTotal - lowerTotal)) + ); + return lower.map( + (width, i) => width + ((upper[i] ?? width) - width) * progress + ); +} + +/** CSS tables cap accumulated intrinsic column percentages at 100%. */ +function normalizePercentages( + declaredWidths: Array, + columnCount: number +): Array { + const percentages: Array = []; + let remaining = 1; + for (let i = 0; i < columnCount; i++) { + const percent = declaredWidths[i]?.percent; + if (percent == null || percent <= 0) { + percentages[i] = null; + } else { + percentages[i] = Math.min(percent, remaining); + remaining = Math.max(0, remaining - percent); + } + } + return percentages; +} + +function addDistributedWidth( + widths: number[], + total: number, + indexes: number[] +): number[] { + if (indexes.length === 0 || total <= 0) { + return widths; + } + const weights = indexes.map((i) => widths[i] ?? 0); + const shares = distribute(total, weights); + return widths.map((width, i) => { + const candidateIndex = indexes.indexOf(i); + return candidateIndex === -1 + ? width + : width + (shares[candidateIndex] ?? 0); + }); +} + +export default function computeColumnWidths( + display: Display, + declaredWidths: Array = [] +): number[] { const contentWidth = display.contentWidth; const shouldStretch = !!display.forceStretch; const columnConstraints = reduceColumnConstraints(display.cells); + const columnCount = Math.max(columnConstraints.length, declaredWidths.length); + for (let i = 0; i < columnCount; i++) { + const constraints = (columnConstraints[i] ??= { + minWidth: 0, + spread: 0, + contentDensity: 0 + }); + const declaredWidth = declaredWidths[i]?.minWidth; + if (declaredWidth != null && declaredWidth > 0) { + // Absolute column widths contribute to intrinsic minimum and preferred + // widths. Percentage widths remain unresolved until distribution below. + constraints.minWidth = Math.max(constraints.minWidth, declaredWidth); + constraints.spread = Math.max(constraints.spread, declaredWidth); + } + } if (columnConstraints.length === 0) { return []; } @@ -43,26 +118,57 @@ export default function computeColumnWidths(display: Display): number[] { // its longest word, so the table overflows and `HTMLTable` scrolls it. return minWidths; } - const widthToAssign = contentWidth - sumOfMinWidths; - // Each column may usefully grow from its minimum up to its maximum, and no - // further. CSS 2.1 §17.5.2.2 shares the surplus over that headroom, so every - // column that can still benefit gets a proportional share — including the - // least demanding one, which must not be starved. - const headrooms = spreads.map((spread, i) => - Math.max(0, spread - (minWidths[i] ?? 0)) + + // Keep percentage columns as a separate sizing class. This is the critical + // difference from resolving percentages to hard pixel minima up front: when + // the full percentage guess does not fit, browsers interpolate back toward + // the min-content guess while keeping the total at the assignable width. + const percentages = normalizePercentages( + declaredWidths, + columnConstraints.length ); - const totalHeadroom = sumOf(headrooms); - if (widthToAssign < totalHeadroom) { - const shares = distribute(widthToAssign, headrooms); - return minWidths.map((min, i) => min + (shares[i] ?? 0)); + const percentageGuess = minWidths.map((minWidth, i) => { + const percent = percentages[i]; + return percent === null || percent === undefined + ? minWidth + : Math.max(minWidth, percent * contentWidth); + }); + const percentageGuessTotal = sumOf(percentageGuess); + if (contentWidth <= percentageGuessTotal) { + return interpolateWidths(minWidths, percentageGuess, contentWidth); } - // Every column can reach its maximum width. Shrink-to-fit leaves the table - // narrower than its container; `forceStretch` instead spreads the remainder - // over the columns, in proportion to how much width each one can put to use. + + // Next move non-percentage columns from min-content toward max-content. A + // percentage column keeps the width assigned by the percentage sizing guess. + const maxContentGuess = percentageGuess.map((width, i) => + percentages[i] == null ? Math.max(width, spreads[i] ?? 0) : width + ); + const maxContentGuessTotal = sumOf(maxContentGuess); + if (contentWidth <= maxContentGuessTotal) { + return interpolateWidths(percentageGuess, maxContentGuess, contentWidth); + } + + // An auto-width table can shrink to its max-content size. An explicitly + // sized table (or forceStretch) must distribute the remaining assignable + // width so that the columns add up to the table width. if (!shouldStretch) { - return spreads; + return maxContentGuess; } - const leftover = widthToAssign - totalHeadroom; - const shares = distribute(leftover, spreads); - return spreads.map((spread, i) => spread + (shares[i] ?? 0)); + const leftover = contentWidth - maxContentGuessTotal; + const autoColumns = maxContentGuess + .map((_, i) => i) + .filter((i) => declaredWidths[i] == null); + if (autoColumns.length > 0) { + return addDistributedWidth(maxContentGuess, leftover, autoColumns); + } + const percentColumns = percentages + .map((percent, i) => (percent == null ? -1 : i)) + .filter((i) => i >= 0); + return addDistributedWidth( + maxContentGuess, + leftover, + percentColumns.length > 0 + ? percentColumns + : maxContentGuess.map((_, i) => i) + ); } diff --git a/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts new file mode 100644 index 0000000..6ede00f --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts @@ -0,0 +1,143 @@ +import { TNode } from '@native-html/render'; +import { + clampWidth, + resolveAttributeSize, + resolveCssSize, + resolvePercentage +} from './resolveWidth'; + +export interface DeclaredColumnWidth { + /** An absolute lower bound contributed by the column or column group. */ + minWidth: number; + /** A preferred fraction of the assignable table width. */ + percent: number | null; +} + +const MAX_SPAN = 1000; + +function parseSpan(value: unknown): number { + const parsed = typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(parsed)) { + return 1; + } + return Math.min(Math.max(Math.floor(parsed), 1), MAX_SPAN); +} + +function appendWidth( + widths: Array, + width: DeclaredColumnWidth | null, + span: number +) { + for (let i = 0; i < span; i++) { + widths.push(width); + } +} + +function mergeWidths( + group: DeclaredColumnWidth | null, + column: DeclaredColumnWidth | null +): DeclaredColumnWidth | null { + if (!group) return column; + if (!column) return group; + return { + minWidth: Math.max(group.minWidth, column.minWidth), + percent: + group.percent === null + ? column.percent + : column.percent === null + ? group.percent + : Math.max(group.percent, column.percent) + }; +} + +/** + * Keep percentage widths unresolved. Browsers carry these as intrinsic + * percentage contributions and reconcile them during width distribution. + */ +function resolveColumnWidth( + tnode: TNode, + containingWidth: number +): DeclaredColumnWidth | null { + const style = tnode.styles.nativeBlockRet; + const cssPercent = resolvePercentage(style.width); + const cssAbsolute = resolveCssSize(style.width, containingWidth); + const hasCssWidth = cssPercent !== null || cssAbsolute !== null; + const attributePercent = hasCssWidth + ? null + : resolvePercentage(tnode.attributes.width); + const percent = cssPercent ?? attributePercent; + // Percentage min-width does not contribute to table-internal percentage + // sizing. Keep only an absolute lower bound here. + const absoluteMin = + typeof style.minWidth === 'number' && Number.isFinite(style.minWidth) + ? Math.max(0, style.minWidth) + : 0; + if (percent !== null) { + const percentageMax = resolvePercentage(style.maxWidth); + return { + minWidth: absoluteMin, + percent: + percentageMax === null ? percent : Math.min(percent, percentageMax) + }; + } + const absolute = + cssAbsolute ?? + (hasCssWidth + ? null + : resolveAttributeSize(tnode.attributes.width, containingWidth)); + if (absolute === null && absoluteMin === 0) { + return null; + } + const absoluteMax = + typeof style.maxWidth === 'number' && Number.isFinite(style.maxWidth) + ? Math.max(0, style.maxWidth) + : null; + return { + minWidth: clampWidth( + absolute ?? absoluteMin, + absoluteMin, + absoluteMax + ), + percent: null + }; +} + +function appendColgroupWidths( + widths: Array, + colgroup: TNode, + containingWidth: number +) { + const groupWidth = resolveColumnWidth(colgroup, containingWidth); + const columns = colgroup.children.filter((child) => child.tagName === 'col'); + if (columns.length === 0) { + appendWidth(widths, groupWidth, parseSpan(colgroup.attributes.span)); + return; + } + for (const column of columns) { + appendWidth( + widths, + mergeWidths(groupWidth, resolveColumnWidth(column, containingWidth)), + parseSpan(column.attributes.span) + ); + } +} + +/** Collect the ordered widths declared by colgroup and col elements. */ +export default function extractColumnWidths( + table: TNode, + containingWidth: number +): Array { + const widths: Array = []; + for (const child of table.children) { + if (child.tagName === 'colgroup') { + appendColgroupWidths(widths, child, containingWidth); + } else if (child.tagName === 'col') { + appendWidth( + widths, + resolveColumnWidth(child, containingWidth), + parseSpan(child.attributes.span) + ); + } + } + return widths; +} diff --git a/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts b/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts new file mode 100644 index 0000000..94ff528 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts @@ -0,0 +1,83 @@ +import { TNode } from '@native-html/render'; + +const PERCENTAGE_REGEX = /^(\d*\.?\d+)%$/; +const UNITLESS_REGEX = /^(\d*\.?\d+)$/; + +/** Resolve a processed CSS width against its containing block. */ +export function resolveCssSize( + value: unknown, + containingWidth: number +): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) && value >= 0 ? value : null; + } + if (typeof value === 'string') { + const percentage = PERCENTAGE_REGEX.exec(value.trim()); + if (percentage) { + return (containingWidth * Number(percentage[1])) / 100; + } + } + return null; +} + +/** Return a CSS/HTML percentage as a ratio without resolving it to pixels. */ +export function resolvePercentage(value: unknown): number | null { + if (typeof value !== 'string') { + return null; + } + const percentage = PERCENTAGE_REGEX.exec(value.trim()); + return percentage ? Number(percentage[1]) / 100 : null; +} + +/** Resolve an HTML presentational width attribute. */ +export function resolveAttributeSize( + value: unknown, + containingWidth: number +): number | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + const percentage = PERCENTAGE_REGEX.exec(trimmed); + if (percentage) { + return (containingWidth * Number(percentage[1])) / 100; + } + const unitless = UNITLESS_REGEX.exec(trimmed); + return unitless ? Number(unitless[1]) : null; +} + +/** Apply the CSS max-width, then min-width clamping order. */ +export function clampWidth( + width: number, + minWidth: number | null, + maxWidth: number | null +): number { + let used = width; + if (maxWidth !== null) { + used = Math.min(used, maxWidth); + } + if (minWidth !== null) { + used = Math.max(used, minWidth); + } + return used; +} + +/** + * Resolve the width imposed by an element. CSS wins over the HTML width hint; + * min/max-width clamp it using normal CSS precedence. + */ +export function resolveNodeWidth( + tnode: TNode, + containingWidth: number +): number | null { + const blockStyle = tnode.styles.nativeBlockRet; + const minWidth = resolveCssSize(blockStyle.minWidth, containingWidth); + const maxWidth = resolveCssSize(blockStyle.maxWidth, containingWidth); + const cssWidth = resolveCssSize(blockStyle.width, containingWidth); + const width = + cssWidth ?? resolveAttributeSize(tnode.attributes.width, containingWidth); + if (width === null && minWidth === null) { + return null; + } + return clampWidth(width ?? minWidth ?? 0, minWidth, maxWidth); +} diff --git a/packages/heuristic-table-plugin/src/index.ts b/packages/heuristic-table-plugin/src/index.ts index 2062342..a2536f2 100644 --- a/packages/heuristic-table-plugin/src/index.ts +++ b/packages/heuristic-table-plugin/src/index.ts @@ -3,6 +3,7 @@ import { HeuristicTablePluginConfig, Settings } from './shared-types'; import TableRenderer from './TableRenderer'; import TdRenderer from './TdRenderer'; import ThRenderer from './ThRenderer'; +import colgroupModel from './ColgroupModel'; export { CellProperties, @@ -17,7 +18,7 @@ export { TableRoot } from './shared-types'; -export { TableRenderer, ThRenderer, TdRenderer }; +export { TableRenderer, ThRenderer, TdRenderer, colgroupModel }; /** * Renderers to be merged in the `renderers` prop of `RenderHTML` component. From 3b0e3597081ebdfc9bb0abcbc096386aea32346a Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Thu, 3 Sep 2026 12:41:50 +0200 Subject: [PATCH 03/21] feat(heuristic-table-plugin): make heuristic table respect external paddings --- packages/heuristic-table-plugin/README.md | 23 +++- .../heuristic-table-plugin/src/HTMLTable.tsx | 17 +-- .../heuristic-table-plugin/src/TableLayout.ts | 49 +++++++-- .../src/helpers/__tests__/TableLayout.test.ts | 69 ++++++++++++ .../__tests__/resolveAvailableWidth.test.ts | 101 ++++++++++++++++++ .../src/helpers/__tests__/utils.ts | 26 ++++- .../src/helpers/measure.ts | 21 ++++ .../src/helpers/resolveAvailableWidth.ts | 49 +++++++++ .../src/shared-types.ts | 13 ++- .../src/useHtmlTableProps.ts | 2 +- 10 files changed, 347 insertions(+), 23 deletions(-) create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts diff --git a/packages/heuristic-table-plugin/README.md b/packages/heuristic-table-plugin/README.md index 746c20b..53f9234 100644 --- a/packages/heuristic-table-plugin/README.md +++ b/packages/heuristic-table-plugin/README.md @@ -177,6 +177,20 @@ problem](https://dl.acm.org/doi/abs/10.1145/304893.304937). To resolve this problem, this library uses a dumb and cheap algorithm, which won't find the *best* solution but instead a visually acceptable layout. +### 0. Available width resolution + +`contentWidth` is published once, at the root of the render tree, and is never +narrowed as the engine descends. Before anything else, the table walks up its +ancestors and subtracts the horizontal spacing each one imposes — padding, +border and margin — along with any explicit width they declare. Its own +margins come off next, and its own padding and border after that, since a +React Native `width` is a border box. What is left is the width its columns +may occupy. + +A table inside `
` therefore lays out against +`contentWidth - 40` and stays inside its parent, rather than overflowing it +into a horizontal scroller. + ### 1. Cell constraints extraction In the first step, each cell of the table is parsed to extract two metrics: @@ -201,4 +215,11 @@ constraint. Otherwise, let `spaceToAllocate = contentWidth - minTableWidth`. Allocate to each column a width equal to its `minWidth` constraint + `spaceToAllocate * gamma`, with `gamma = (normalContentDensity) / sum(normalContentDensities)`. The `normalContentDensity` is `contentDensity - min(contentDensities)`. -Finally, clamp the assign width to the `spread` constraint for this column, unless `forceStretch` parameter is set to `true`. +Finally, clamp the assign width to the `spread` constraint for this column, +unless the `forceStretch` parameter is set to `true`. + +`forceStretch` defaults to `true`, so a table fills the width its containing +block leaves it. Set it to `false` in `renderersProps.table` to let an +auto-width table shrink to fit its content instead. A table with an explicit +width always distributes that width over its columns, whatever `forceStretch` +is set to. diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index 75221a7..ea5b6eb 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -2,7 +2,7 @@ import React, { memo, PropsWithChildren } from 'react'; import { ScrollView, View } from 'react-native'; import TreeRenderer from './TreeRenderer'; import { HTMLTableProps } from './shared-types'; -import { getHorizontalSpacing } from './helpers/measure'; +import { getHorizontalInsets } from './helpers/measure'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; export function shouldScrollTable( @@ -52,7 +52,11 @@ const HTMLTable = memo(function HTMLTable({ ...props }: HTMLTableProps) { const tableWidth = layout.totalWidth; - const containerWidth = settings.contentWidth; + // `layout` measures against the width the table's ancestors actually leave + // it, which is what `contentWidth` would be if it were narrowed on the way + // down the tree. Sizing the container off `settings.contentWidth` instead + // would spill the table out of every padded ancestor it sits in. + const insets = getHorizontalInsets(props.tnode.styles.nativeBlockRet); return ( - + {React.createElement(TreeRenderer, { node: layout.renderTree, config, diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 265b62f..30fba4e 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -9,21 +9,57 @@ import TCellConstraintsComputer from './helpers/TCellConstraintsComputer'; import { Display, Settings, TableRoot } from './shared-types'; import extractColumnWidths from './helpers/extractColumnWidths'; import { resolveNodeWidth } from './helpers/resolveWidth'; +import resolveAvailableWidth from './helpers/resolveAvailableWidth'; +import { getHorizontalInsets, getHorizontalMargins } from './helpers/measure'; + +/** + * Tables fill the width their containing block leaves them unless the config + * opts out, so that a table reads as part of the surrounding document rather + * than as a shrink-wrapped island. + */ +const DEFAULT_FORCE_STRETCH = true; export default class TableLayout { public readonly display: Display; public readonly columnWidths: number[]; public readonly totalWidth: number; + /** + * The border-box width the table may occupy, after the horizontal spacing of + * every ancestor and the table's own margins have been subtracted from + * {@link Settings.contentWidth}. + */ + public readonly availableWidth: number; + /** + * The width the columns may occupy: {@link TableLayout.availableWidth} less + * the table's own padding and border, which sit inside its border box. + */ + public readonly assignableWidth: number; public readonly renderTree: TableRoot; constructor(tnode: TNode, config: Settings) { - const declaredTableWidth = resolveNodeWidth(tnode, config.contentWidth); - const layoutContentWidth = declaredTableWidth ?? config.contentWidth; + const style = tnode.styles.nativeBlockRet; + const containingWidth = resolveAvailableWidth(tnode, config.contentWidth); + const insets = getHorizontalInsets(style); + const availableWidth = Math.max( + 0, + containingWidth - getHorizontalMargins(style) + ); + // A percentage table width resolves against the containing block, whereas + // the columns are laid out inside the table's own padding and border. + const declaredTableWidth = resolveNodeWidth(tnode, containingWidth); + this.availableWidth = availableWidth; + this.assignableWidth = Math.max(0, availableWidth - insets); + const layoutContentWidth = Math.max( + 0, + (declaredTableWidth ?? availableWidth) - insets + ); const layoutSettings = { ...config, contentWidth: layoutContentWidth, // A table with a specified width distributes that width over its columns; - // shrink-to-fit only applies when the table width is auto. - forceStretch: config.forceStretch || declaredTableWidth !== null + // shrink-to-fit only applies when the table width is auto, and is opt-in. + forceStretch: + (config.forceStretch ?? DEFAULT_FORCE_STRETCH) || + declaredTableWidth !== null }; const computer = new TCellConstraintsComputer({ contentWidth: layoutContentWidth @@ -31,10 +67,7 @@ export default class TableLayout { this.display = createEmptyDisplay(layoutSettings); fillTableDisplay(tnode, this.display, computer); const declaredColumnWidths = extractColumnWidths(tnode, layoutContentWidth); - this.columnWidths = computeColumnWidths( - this.display, - declaredColumnWidths - ); + this.columnWidths = computeColumnWidths(this.display, declaredColumnWidths); this.totalWidth = sum(this.columnWidths); this.renderTree = createRenderTree(this.display, this.columnWidths); } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts index 755d798..774d037 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts @@ -192,4 +192,73 @@ describe('TableLayout', () => { { x: 2, y: 1 } ]); }); + + describe('containing block', () => { + const rows = 'alphabeta'; + + it('should lay out against the width left by a padded ancestor', () => { + const { assignableWidth, availableWidth, totalWidth } = layoutFor( + `
${rows}
`, + { contentWidth: 400, forceStretch: true } + ); + expect(availableWidth).toBe(340); + expect(assignableWidth).toBe(340); + expect(totalWidth).toBeCloseTo(340); + }); + + it('should resolve a percentage table width against the padded ancestor', () => { + const { totalWidth } = layoutFor( + `
${rows}
`, + { contentWidth: 400, forceStretch: false } + ); + expect(totalWidth).toBeCloseTo(180); + }); + + it('should keep the columns inside the table own padding and border', () => { + // `width` is a border box in React Native, so padding and border eat into + // the space the columns may use rather than adding to the table width. + const { assignableWidth, availableWidth, totalWidth } = layoutFor( + `${rows}
`, + { contentWidth: 400, forceStretch: true } + ); + expect(availableWidth).toBe(400); + expect(assignableWidth).toBe(378); + expect(totalWidth).toBeCloseTo(378); + }); + + it('should take the table own margins out of the width it may occupy', () => { + const { assignableWidth, availableWidth } = layoutFor( + `${rows}
`, + { contentWidth: 400, forceStretch: true } + ); + expect(availableWidth).toBe(350); + expect(assignableWidth).toBe(350); + }); + + it('should stretch to the available width by default', () => { + const { totalWidth } = layoutFor(`${rows}
`, { + contentWidth: 400 + }); + expect(totalWidth).toBeCloseTo(400); + }); + + it('should shrink to fit when forceStretch is disabled', () => { + const { totalWidth } = layoutFor(`${rows}
`, { + contentWidth: 400, + forceStretch: false + }); + expect(totalWidth).toBeLessThan(400); + }); + + it('should still overflow when the minimum widths do not fit', () => { + const { totalWidth, assignableWidth } = layoutFor( + `
+
AB
+
`, + { contentWidth: 400, forceStretch: true } + ); + expect(assignableWidth).toBe(300); + expect(totalWidth).toBeGreaterThanOrEqual(600); + }); + }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts new file mode 100644 index 0000000..383b0bc --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts @@ -0,0 +1,101 @@ +import resolveAvailableWidth from '../resolveAvailableWidth'; +import { createTableTNode } from './utils'; + +function availableWidthFor(html: string, contentWidth: number) { + return resolveAvailableWidth(createTableTNode(html), contentWidth); +} + +describe('resolveAvailableWidth', () => { + it('should return contentWidth when no ancestor imposes spacing', () => { + expect(availableWidthFor('
A
', 400)).toBe( + 400 + ); + }); + + it('should subtract the padding of an ancestor', () => { + expect( + availableWidthFor( + '
A
', + 400 + ) + ).toBe(360); + }); + + it('should subtract the border and margin of an ancestor', () => { + expect( + availableWidthFor( + `
+
A
+
`, + 400 + ) + ).toBe(400 - 16 - 4); + }); + + it('should accumulate the spacing of every ancestor', () => { + expect( + availableWidthFor( + `
+
+
A
+
+
`, + 400 + ) + ).toBe(400 - 40 - 15 - 5); + }); + + it('should treat an explicit ancestor width as a border box', () => { + // `width` in React Native already contains padding and border, so only the + // padding may be taken out of it — subtracting the margins too would + // shrink the table below the box its ancestor actually occupies. + expect( + availableWidthFor( + `
+
A
+
`, + 400 + ) + ).toBe(280); + }); + + it('should resolve an ancestor percentage width against its own container', () => { + expect( + availableWidthFor( + `
+
A
+
`, + 400 + ) + ).toBe(190); + }); + + it('should never report a negative width', () => { + expect( + availableWidthFor( + '
A
', + 300 + ) + ).toBe(0); + }); + + it('should cap an auto-width ancestor at its max-width', () => { + expect( + availableWidthFor( + `
+
A
+
`, + 400 + ) + ).toBe(280); + }); + + it('should leave an ancestor alone when its max-width is not reached', () => { + expect( + availableWidthFor( + '
A
', + 400 + ) + ).toBe(400); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts index 9f3bc20..8a6c065 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts @@ -1,4 +1,5 @@ import { TRenderEngine } from '@native-html/transient-render-engine'; +import { TNode } from '@native-html/render'; import colgroupModel from '../../ColgroupModel'; const engine = new TRenderEngine({ @@ -10,9 +11,26 @@ const engine = new TRenderEngine({ } }); +function findTable(tnode: TNode): TNode | null { + if (tnode.tagName === 'table') { + return tnode; + } + for (const child of tnode.children) { + const table = findTable(child); + if (table) { + return table; + } + } + return null; +} + +/** + * Build a transient render tree from `html` and return its first `table`, + * however deeply it is nested. The tnode keeps its ancestors, so helpers which + * walk up the tree see the real containing blocks. + */ export function createTableTNode(html: string) { - const ttree = engine.buildTTree(html); - const table = ttree.children[0].children[0]; - expect(table.tagName).toBe('table'); - return table; + const table = findTable(engine.buildTTree(html) as unknown as TNode); + expect(table?.tagName).toBe('table'); + return table as TNode; } diff --git a/packages/heuristic-table-plugin/src/helpers/measure.ts b/packages/heuristic-table-plugin/src/helpers/measure.ts index bce62ca..b7ab5da 100644 --- a/packages/heuristic-table-plugin/src/helpers/measure.ts +++ b/packages/heuristic-table-plugin/src/helpers/measure.ts @@ -13,6 +13,13 @@ type SpacingFields = Extract< const hmarginFields: readonly SpacingFields[] = ['marginLeft', 'marginRight']; +const hinsetFields: readonly SpacingFields[] = [ + 'borderLeftWidth', + 'borderRightWidth', + 'paddingLeft', + 'paddingRight' +]; + const hspacingFields: readonly SpacingFields[] = [ 'borderLeftWidth', 'borderRightWidth', @@ -36,6 +43,20 @@ export function getHorizontalMargins(style: NativeBlockRetStyle): number { return sumFields(style, hmarginFields); } +/** + * The horizontal spacing that sits *inside* a border box. + * + * @remarks + * React Native lays out with `box-sizing: border-box`, so an element's width + * already contains its padding and border: only what is left of that width is + * offered to its children. Margins are excluded here because they sit outside + * the box, and so reduce the width the element itself may take rather than the + * width it may pass on. + */ +export function getHorizontalInsets(style: NativeBlockRetStyle): number { + return sumFields(style, hinsetFields); +} + export function getHorizontalSpacing(style: NativeBlockRetStyle): number { return sumFields(style, hspacingFields); } diff --git a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts new file mode 100644 index 0000000..82b5bc3 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts @@ -0,0 +1,49 @@ +import { TNode } from '@native-html/render'; +import { getHorizontalInsets, getHorizontalMargins } from './measure'; +import { clampWidth, resolveCssSize, resolveNodeWidth } from './resolveWidth'; + +/** + * The width `tnode` offers to a block-level child, i.e. its content box. + */ +function reduceToContentBox(tnode: TNode, containingWidth: number): number { + const style = tnode.styles.nativeBlockRet; + // A declared width is a border box in React Native, so it already accounts + // for padding and border; an auto width fills the containing block, minus + // the margins that sit outside the box, and is still capped by `max-width`. + const declaredWidth = resolveNodeWidth(tnode, containingWidth); + const borderBox = + declaredWidth ?? + clampWidth( + containingWidth - getHorizontalMargins(style), + resolveCssSize(style.minWidth, containingWidth), + resolveCssSize(style.maxWidth, containingWidth) + ); + return Math.max(0, borderBox - getHorizontalInsets(style)); +} + +/** + * Resolve the width the containing block of `tnode` actually offers. + * + * @remarks + * `contentWidth` is published once, at the root of the render tree, and is + * never narrowed as the engine descends. A node nested in padded, bordered or + * explicitly sized ancestors therefore has to subtract their horizontal + * spacing itself — otherwise it lays out against a width it was never given + * and overflows every one of them. + * + * @param tnode - The node whose containing block should be measured. + * @param contentWidth - The width available at the root of the render tree. + */ +export default function resolveAvailableWidth( + tnode: TNode, + contentWidth: number +): number { + const ancestors: TNode[] = []; + for (let parent = tnode.parent; parent; parent = parent.parent) { + ancestors.unshift(parent); + } + return ancestors.reduce( + (width, ancestor) => reduceToContentBox(ancestor, width), + contentWidth + ); +} diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 5893b71..5818cd5 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -145,7 +145,12 @@ export interface Settings { */ forceStretch?: boolean; /** - * Available width prior to scrolling. + * Available width at the root of the render tree, prior to scrolling. + * + * @remarks + * This is the width offered to the document as a whole. The horizontal + * spacing of the table's ancestors, and of the table itself, is subtracted + * from it by {@link TableLayout}. */ contentWidth: number; } @@ -165,7 +170,11 @@ export interface Display extends Settings { */ export interface HeuristicTablePluginConfig { /** - * When true, force the table to stretch to the available width. + * When true, the table stretches to fill the width its containing block + * offers — `contentWidth`, less the horizontal spacing of every ancestor. + * When false, a table with an auto width shrinks to fit its content. + * + * @defaultValue true */ forceStretch?: boolean; /** diff --git a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts index bdc87f2..53db8c4 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts @@ -41,7 +41,7 @@ export default function useHtmlTableProps( } = {} ): HTMLTableProps { const table = useRendererProps('table'); - const forceStretch = table?.forceStretch ?? false; + const forceStretch = table?.forceStretch; const sharedContentWidth = useContentWidth(); const contentWidth = typeof options.overrideContentWidth === 'number' From 2eb8ec5c7863fb98d0f3a3db0f6f1a6820bc94be Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 8 Sep 2026 16:55:00 +0200 Subject: [PATCH 04/21] feat(heuristic-table-plugin): enable configuring font coeffs for tables --- packages/heuristic-table-plugin/README.md | 36 ++- ...table-plugin.default_font_weight_coeffs.md | 18 ++ ...tic-table-plugin.fontweightcoefficients.md | 18 ++ ...euristictablepluginconfig.basefontcoeff.md | 18 ++ ...istictablepluginconfig.fontweightcoeffs.md | 18 ++ ...heuristictablepluginconfig.forcestretch.md | 2 +- ...table-plugin.heuristictablepluginconfig.md | 40 ++- .../docs/heuristic-table-plugin.md | 37 +++ .../etc/heuristic-table-plugin.api.md | 8 + .../heuristic-table-plugin/src/HTMLTable.tsx | 7 +- .../heuristic-table-plugin/src/TableLayout.ts | 77 ++++- .../src/helpers/TCellConstraintsComputer.ts | 124 ++++++-- .../TCellConstraintsComputer.test.ts | 109 ++++++- .../src/helpers/__tests__/TableLayout.test.ts | 284 +++++++++++++++++- .../__tests__/resolveAvailableWidth.test.ts | 23 ++ .../src/helpers/computeColumnWidths.ts | 168 ++++++++--- .../src/helpers/extractColumnWidths.ts | 162 ++++++---- .../src/helpers/resolveAvailableWidth.ts | 24 +- .../src/helpers/resolveWidth.ts | 109 ++++++- packages/heuristic-table-plugin/src/index.ts | 5 + .../src/shared-types.ts | 55 ++++ .../src/useHtmlTableProps.ts | 11 +- packages/table-plugin/src/HTMLTable.tsx | 10 - 23 files changed, 1156 insertions(+), 207 deletions(-) create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.default_font_weight_coeffs.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.fontweightcoefficients.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.basefontcoeff.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.fontweightcoeffs.md diff --git a/packages/heuristic-table-plugin/README.md b/packages/heuristic-table-plugin/README.md index 53f9234..9902091 100644 --- a/packages/heuristic-table-plugin/README.md +++ b/packages/heuristic-table-plugin/README.md @@ -193,11 +193,15 @@ into a horizontal scroller. ### 1. Cell constraints extraction -In the first step, each cell of the table is parsed to extract two metrics: -`minWidth` and `contentDensity`. `minWidth` is an estimation of the width taken -by the longest word in the cell, or the explicit width or min-width of any -block in the cell, or the greatest of the two. `contentDensity` is the width -taken by all the text displayed in one line. +In the first step, each cell of the table is parsed to extract three metrics: + +- `minWidth`, an estimate of the cell's min-content width: its longest + unbreakable text run or the greatest width imposed by one of its blocks, + plus horizontal spacing; +- `maxWidth`, the width beyond which the cell would gain nothing, bounded by + the cell's own `max-width` but never below `minWidth`; +- `contentDensity`, an estimate of the width taken by all the cell's text on + one line. ### 2. Column constraints reduction @@ -205,18 +209,26 @@ In the second step, cell constraints are reduced per column. Three metrics come - `minWidth`, the maximum of each cell `minWidth`; - `contentDensity`, the sum of each cell `contentDensity`; -- `spread`, the maximum of each cell `contentDensity`. +- `spread`, the maximum of each cell `maxWidth`, never below the column's + `minWidth`. + +Widths and bounds declared by `` and `` are then folded into +these constraints. Percentage widths remain unresolved until the table's +assignable width is known. ### 3. Column widths calculation -Let `minTableWidth` be the sum of all column `minWidth`. If `minTableWidth > -contentWidth`, assign to each column a width corresponding to its `minWidth` -constraint. +If the sum of the column minimums exceeds the assignable width, every column +keeps its `minWidth` and the table scrolls horizontally. Otherwise, the +algorithm grows columns in passes: -Otherwise, let `spaceToAllocate = contentWidth - minTableWidth`. Allocate to each column a width equal to its `minWidth` constraint + `spaceToAllocate * gamma`, with `gamma = (normalContentDensity) / sum(normalContentDensities)`. The `normalContentDensity` is `contentDensity - min(contentDensities)`. +1. Percentage columns move from their minimums toward their declared shares. +2. Auto and absolute-width columns move toward their max-content `spread`. +3. When the table must stretch, any remaining width is distributed among + columns that have room below their declared caps. -Finally, clamp the assign width to the `spread` constraint for this column, -unless the `forceStretch` parameter is set to `true`. +Each intermediate pass is interpolated to keep the result within the +assignable table width. Space that no column can accept is left unassigned. `forceStretch` defaults to `true`, so a table fills the width its containing block leaves it. Set it to `false` in `renderersProps.table` to let an diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.default_font_weight_coeffs.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.default_font_weight_coeffs.md new file mode 100644 index 0000000..6741a2e --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.default_font_weight_coeffs.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [DEFAULT\_FONT\_WEIGHT\_COEFFS](./heuristic-table-plugin.default_font_weight_coeffs.md) + +## DEFAULT\_FONT\_WEIGHT\_COEFFS variable + +The coefficients used when the config supplies none. + +**Signature:** + +```typescript +DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients +``` + +## Remarks + +A user-supplied map is merged over these rather than replacing them, so a config may retune `bold` alone without restating all nine numeric weights. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.fontweightcoefficients.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.fontweightcoefficients.md new file mode 100644 index 0000000..a6c619b --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.fontweightcoefficients.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [FontWeightCoefficients](./heuristic-table-plugin.fontweightcoefficients.md) + +## FontWeightCoefficients type + +How much wider text renders at a given font weight than at a regular one. + +**Signature:** + +```typescript +export type FontWeightCoefficients = Record; +``` + +## Remarks + +Keys are matched against the resolved `fontWeight` stringified, so both the numeric weights React Native accepts and the `normal`/`bold` keywords are looked up here. A weight with no entry falls back to a coefficient of 1. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.basefontcoeff.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.basefontcoeff.md new file mode 100644 index 0000000..5c35357 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.basefontcoeff.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md) > [baseFontCoeff](./heuristic-table-plugin.heuristictablepluginconfig.basefontcoeff.md) + +## HeuristicTablePluginConfig.baseFontCoeff property + +The average advance width of one character, as a fraction of the font size, used to estimate how wide a cell's text is. + +**Signature:** + +```typescript +baseFontCoeff?: number; +``` + +## Remarks + +Text is never measured, only estimated: a cell's bounds are its character count times this coefficient times the font size. Raise it when tables come out too narrow and their text wraps more than it should, lower it when cells claim more width than their content occupies. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.fontweightcoeffs.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.fontweightcoeffs.md new file mode 100644 index 0000000..c58a04b --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.fontweightcoeffs.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md) > [fontWeightCoeffs](./heuristic-table-plugin.heuristictablepluginconfig.fontweightcoeffs.md) + +## HeuristicTablePluginConfig.fontWeightCoeffs property + +How much wider text renders at a given font weight than at a regular one, keyed by the stringified `fontWeight`. + +**Signature:** + +```typescript +fontWeightCoeffs?: FontWeightCoefficients; +``` + +## Remarks + +Merged over the defaults rather than replacing them, so `{ bold: 1.05 }` retunes bold text alone and leaves the numeric weights as they were. A weight with no entry, before or after merging, costs nothing. Pass a referentially stable object — a fresh literal on every render relays out every table using it. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.forcestretch.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.forcestretch.md index f5d7ad8..fbd739f 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.forcestretch.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.forcestretch.md @@ -4,7 +4,7 @@ ## HeuristicTablePluginConfig.forceStretch property -When true, force the table to stretch to the available width. +When true, the table stretches to fill the width its containing block offers — `contentWidth`, less the horizontal spacing of every ancestor. When false, a table with an auto width shrinks to fit its content. **Signature:** diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md index e9ae0f7..99d2254 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md @@ -37,6 +37,44 @@ Description +[baseFontCoeff?](./heuristic-table-plugin.heuristictablepluginconfig.basefontcoeff.md) + + + + + + + +number + + + + +_(Optional)_ The average advance width of one character, as a fraction of the font size, used to estimate how wide a cell's text is. + + + + + +[fontWeightCoeffs?](./heuristic-table-plugin.heuristictablepluginconfig.fontweightcoeffs.md) + + + + + + + +[FontWeightCoefficients](./heuristic-table-plugin.fontweightcoefficients.md) + + + + +_(Optional)_ How much wider text renders at a given font weight than at a regular one, keyed by the stringified `fontWeight`. + + + + + [forceStretch?](./heuristic-table-plugin.heuristictablepluginconfig.forcestretch.md) @@ -50,7 +88,7 @@ boolean -_(Optional)_ When true, force the table to stretch to the available width. +_(Optional)_ When true, the table stretches to fill the width its containing block offers — `contentWidth`, less the horizontal spacing of every ancestor. When false, a table with an auto width shrinks to fit its content. diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md index d867b1b..21d154e 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md @@ -187,6 +187,17 @@ Description Element model required for colgroup children to be available to the table layout engine. Col elements remain non-rendering empty nodes. + + + +[DEFAULT\_FONT\_WEIGHT\_COEFFS](./heuristic-table-plugin.default_font_weight_coeffs.md) + + + + +The coefficients used when the config supplies none. + + @@ -245,3 +256,29 @@ The renderer component for `th` tag. +## Type Aliases + + + +
+ +Type Alias + + + + +Description + + +
+ +[FontWeightCoefficients](./heuristic-table-plugin.fontweightcoefficients.md) + + + + +How much wider text renders at a given font weight than at a regular one. + + +
+ diff --git a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md index 5bd3dc1..292c469 100644 --- a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md +++ b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md @@ -37,14 +37,22 @@ export interface Coordinates { y: number; } +// @public +export const DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients; + // @public (undocumented) export interface DisplayCell extends CellProperties { // (undocumented) tnode: TNode; } +// @public +export type FontWeightCoefficients = Record; + // @public export interface HeuristicTablePluginConfig { + baseFontCoeff?: number; + fontWeightCoeffs?: FontWeightCoefficients; forceStretch?: boolean; getStyleForCell?(cell: TableCell): ViewStyle | null; } diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index ea5b6eb..49c75d2 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -64,7 +64,12 @@ const HTMLTable = memo(function HTMLTable({ // An explicit height on a table is a minimum height in HTML, so that // the table still grows to fit its rows. ...relaxHeightConstraint(props.style), - width: Math.min(tableWidth + insets, layout.availableWidth) + // `usedWidth` already accounts for both the room the ancestors leave + // and the table's own `width`/`max-width`, so the painted box stops at + // whichever of the two comes first and the overflow goes to the + // scroller inside. A table narrower than that keeps its own size, + // insets included. + width: Math.min(tableWidth + insets, layout.usedWidth) }}> sum(columnWidths)) { + columnWidths = raisedColumnWidths; + } + } + this.columnWidths = columnWidths; + this.totalWidth = sum(columnWidths); this.renderTree = createRenderTree(this.display, this.columnWidths); } } diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index 9be5548..d774cdc 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -6,7 +6,7 @@ import reduce from 'ramda/src/reduce'; import { TNode } from '@native-html/render'; import { TCellConstraints, TConstraintsBase } from '../shared-types'; import { getHorizontalMargins, getHorizontalSpacing } from './measure'; -import { resolveCssSize, resolveNodeWidth } from './resolveWidth'; +import { resolveCssSize, resolveImposedWidth } from './resolveWidth'; interface TextChunkStats { fontWeightCoeff: number; @@ -48,54 +48,111 @@ function getInitCellStatsForTnode(tnode: TNode): TCellStats { }; } +/** + * Whitespace that forbids a line break rather than offering one. + * + * @remarks + * `\s` cannot be used on its own to find break opportunities, because it also + * matches the spaces authors reach for precisely to keep two words together: + * U+00A0 NO-BREAK SPACE, U+202F NARROW NO-BREAK SPACE and U+2007 FIGURE SPACE + * are all glue in {@link https://www.unicode.org/reports/tr14/ | UAX #14}, and + * U+FEFF is a word joiner. `10 000 km` is one unbreakable run of ten + * characters, not three of two, three and two. + */ +const NON_BREAKING_SPACE_REGEX = /[\u00a0\u202f\u2007\ufeff]/u; + +const DIGIT_REGEX = /^\d$/u; + +function isBreakingSpace(character: string): boolean { + return /\s/u.test(character) && !NON_BREAKING_SPACE_REGEX.test(character); +} + +function isDigit(character: string | undefined): boolean { + return character !== undefined && DIGIT_REGEX.test(character); +} + function getMaxUnbreakableTextLength(text: string): number { + const characters = Array.from(text); let currentLength = 0; let maxLength = 0; - for (const character of text) { - if (/\s/u.test(character)) { + for (let i = 0; i < characters.length; i++) { + const character = characters[i] as string; + if (isBreakingSpace(character)) { currentLength = 0; continue; } currentLength += character.length; - // A line can break after a regular hyphen. Keep the hyphen in the - // preceding segment because it still occupies space at the line end. - // U+2011 NON-BREAKING HYPHEN is deliberately not included. - if (character === '-' || character === '\u2010') { - maxLength = Math.max(maxLength, currentLength); + maxLength = Math.max(maxLength, currentLength); + // A line can break after a regular hyphen, but never between two digits + // (UAX #14 LB25) — that would split `2026-09-03` or a phone number across + // two lines. Keep the hyphen in the preceding segment because it still + // occupies space at the line end. U+2011 NON-BREAKING HYPHEN is + // deliberately not included. + const isHyphen = character === '-' || character === '\u2010'; + if ( + isHyphen && + !(isDigit(characters[i - 1]) && isDigit(characters[i + 1])) + ) { currentLength = 0; - } else { - maxLength = Math.max(maxLength, currentLength); } } return maxLength; } +/** + * How much wider text renders at a given font weight than at a regular one. + * + * @remarks + * Keys are matched against the resolved `fontWeight` stringified, so both the + * numeric weights React Native accepts and the `normal`/`bold` keywords are + * looked up here. A weight with no entry falls back to a coefficient of 1. + * + * @public + */ +export type FontWeightCoefficients = Record; + +/** + * The coefficients used when the config supplies none. + * + * @remarks + * A user-supplied map is merged over these rather than replacing them, so a + * config may retune `bold` alone without restating all nine numeric weights. + * + * @public + */ +export const DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients = { + '100': 0.8, + '200': 0.85, + '300': 0.9, + '400': 1, + '500': 1.1, + '600': 1.2, + '700': 1.3, + '800': 1.4, + '900': 1.5, + bold: 1.3, + normal: 1 +}; + export default class TCellConstraintsComputer { private baseFontCoeff: number; private fallbackFontSize: number; private contentWidth: number; - - private fontWeightCoeffs: Record = { - '100': 0.8, - '200': 0.85, - '300': 0.9, - '400': 1, - '500': 1.1, - '600': 1.2, - '700': 1.3, - '800': 1.4, - '900': 1.5, - bold: 1.3, - normal: 1 - }; + private fontWeightCoeffs: FontWeightCoefficients; constructor({ baseFontCoeff, fallbackFontSize, + fontWeightCoeffs, contentWidth }: { baseFontCoeff?: number; fallbackFontSize?: number; + /** + * Per-weight width coefficients, merged over + * {@link DEFAULT_FONT_WEIGHT_COEFFS}. + */ + fontWeightCoeffs?: FontWeightCoefficients; /** * The width of the table's containing block, against which percentage * widths are resolved. @@ -104,6 +161,9 @@ export default class TCellConstraintsComputer { }) { this.baseFontCoeff = baseFontCoeff ?? 0.65; this.fallbackFontSize = fallbackFontSize ?? 14; + this.fontWeightCoeffs = fontWeightCoeffs + ? { ...DEFAULT_FONT_WEIGHT_COEFFS, ...fontWeightCoeffs } + : DEFAULT_FONT_WEIGHT_COEFFS; this.contentWidth = contentWidth ?? 0; } @@ -127,7 +187,8 @@ export default class TCellConstraintsComputer { private assembleCellStats( tnode: TNode, - stats: TCellStats = getInitCellStatsForTnode(tnode) + stats: TCellStats = getInitCellStatsForTnode(tnode), + isCellRoot = true ): TCellStats { if (tnode.type === 'text') { const fontSize = @@ -143,13 +204,13 @@ export default class TCellConstraintsComputer { }); } else { if (tnode.type === 'block') { - const width = this.resolveBlockWidth(tnode); + const width = this.resolveBlockWidth(tnode, isCellRoot); if (width !== null) { const margins = getHorizontalMargins(tnode.styles.nativeBlockRet); stats.blockWidth = Math.max(stats.blockWidth, width + margins); } } - tnode.children.forEach((n) => this.assembleCellStats(n, stats)); + tnode.children.forEach((n) => this.assembleCellStats(n, stats, false)); } return stats; } @@ -166,8 +227,13 @@ export default class TCellConstraintsComputer { * presentational `width` attribute is consulted last, as befits a hint of the * lowest priority. */ - private resolveBlockWidth(tnode: TNode): number | null { - return resolveNodeWidth(tnode, this.contentWidth); + private resolveBlockWidth(tnode: TNode, isCellRoot: boolean): number | null { + return resolveImposedWidth(tnode, this.contentWidth, { + // The cell's percentage width resolves against the table. A descendant's + // percentage resolves against the eventual cell content box, which is + // precisely what this intrinsic-width pass is still trying to discover. + resolvePercentages: isCellRoot + }); } private computeTextConstraints(chunks: TextChunkStats[]): TConstraintsBase { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index 8c6411e..e4176a5 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -1,5 +1,8 @@ import { TNode } from '@native-html/render'; -import TCellConstraintsComputer from '../TCellConstraintsComputer'; +import TCellConstraintsComputer, { + DEFAULT_FONT_WEIGHT_COEFFS, + FontWeightCoefficients +} from '../TCellConstraintsComputer'; import { TCellConstraints } from '../../shared-types'; import { createTableTNode } from './utils'; @@ -16,29 +19,107 @@ function findFirstCell(tnode: TNode): TNode | null { return null; } -function constraintsFor(cellMarkup: string, contentWidth = 400): TCellConstraints { +/** + * Pinned here so that the break-opportunity assertions below test the segment + * a string breaks into, and not whatever character-width estimate the computer + * happens to default to. + */ +const BASE_FONT_COEFF = 0.65; + +function constraintsFor( + cellMarkup: string, + contentWidth = 400, + fontWeightCoeffs?: FontWeightCoefficients +): TCellConstraints { const table = createTableTNode(`${cellMarkup}
`); const cell = findFirstCell(table); expect(cell).not.toBeNull(); - return new TCellConstraintsComputer({ contentWidth }).computeCellConstraints( - cell as TNode - ); + return new TCellConstraintsComputer({ + contentWidth, + baseFontCoeff: BASE_FONT_COEFF, + fontWeightCoeffs + }).computeCellConstraints(cell as TNode); } describe('TCellConstraintsComputer', () => { + describe('font weight coefficients', () => { + it('should widen bold text by the default coefficient', () => { + const { minWidth } = constraintsFor( + 'Method' + ); + + expect(minWidth).toBeCloseTo( + 6 * 14 * BASE_FONT_COEFF * (DEFAULT_FONT_WEIGHT_COEFFS.bold as number) + ); + }); + + it('should apply a coefficient supplied by the config', () => { + const { minWidth } = constraintsFor( + 'Method', + 400, + { bold: 1 } + ); + + // A cell of bold text now measures exactly as one of regular text. + expect(minWidth).toBeCloseTo(6 * 14 * BASE_FONT_COEFF); + }); + + it('should keep the defaults a partial config leaves untouched', () => { + // Only `bold` is retuned, so a `font-weight: 300` cell must still use + // the default 0.9 rather than falling back to 1. + const { minWidth } = constraintsFor( + 'Method', + 400, + { bold: 1 } + ); + + expect(minWidth).toBeCloseTo( + 6 * 14 * BASE_FONT_COEFF * (DEFAULT_FONT_WEIGHT_COEFFS['300'] as number) + ); + }); + }); + describe('text break opportunities', () => { it('should allow a line break after a hyphen', () => { const { minWidth } = constraintsFor('Medium-High'); // The longest unbreakable segment is "Medium-" (7 characters), not the // full 11-character string. - expect(minWidth).toBeCloseTo(7 * 14 * 0.65); + expect(minWidth).toBeCloseTo(7 * 14 * BASE_FONT_COEFF); }); it('should retain a non-breaking hyphen in one segment', () => { const { minWidth } = constraintsFor('Medium‑High'); - expect(minWidth).toBeCloseTo(11 * 14 * 0.65); + expect(minWidth).toBeCloseTo(11 * 14 * BASE_FONT_COEFF); + }); + + it('should not break a hyphen between two digits', () => { + // UAX #14 LB25 forbids it, and a date column that wraps mid-value is + // worse than a wide one. + const { minWidth } = constraintsFor('2026-09-03'); + + expect(minWidth).toBeCloseTo(10 * 14 * BASE_FONT_COEFF); + }); + + it('should still break a hyphen with a digit on only one side', () => { + // "ISO-" is the longest segment; the digits stand alone after the break. + const { minWidth } = constraintsFor('ISO-2026'); + + expect(minWidth).toBeCloseTo(4 * 14 * BASE_FONT_COEFF); + }); + + it('should not break at a non-breaking space', () => { + // A whole grouped number is one unbreakable run of nine characters. + const { minWidth } = constraintsFor('10 000 km'); + + expect(minWidth).toBeCloseTo(9 * 14 * BASE_FONT_COEFF); + }); + + it('should break at a regular space', () => { + const { minWidth } = constraintsFor('10 000 km'); + + expect(minWidth).toBeCloseTo(3 * 14 * BASE_FONT_COEFF); }); }); @@ -51,6 +132,13 @@ describe('TCellConstraintsComputer', () => { expect(minWidth).toBeLessThan(220); }); + it('should not resolve a descendant percentage against the table', () => { + const { minWidth } = constraintsFor( + '
a
' + ); + expect(minWidth).toBeLessThan(50); + }); + it('should honour an absolute width', () => { const { minWidth } = constraintsFor('a'); expect(minWidth).toBeGreaterThanOrEqual(200); @@ -76,6 +164,13 @@ describe('TCellConstraintsComputer', () => { const { minWidth } = constraintsFor('a'); expect(minWidth).toBeLessThan(50); }); + + it('should let CSS auto override the presentational width attribute', () => { + const { minWidth } = constraintsFor( + 'a' + ); + expect(minWidth).toBeLessThan(50); + }); }); describe('CSS clamping order', () => { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts index 774d037..92151ea 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts @@ -1,4 +1,5 @@ import TableLayout from '../../TableLayout'; +import { shouldScrollTable } from '../../HTMLTable'; import { Settings } from '../../shared-types'; import { createTableTNode } from './utils'; @@ -87,6 +88,17 @@ describe('TableLayout', () => { expect(columnWidths[0]).toBe(100); }); + it('should let CSS auto suppress a col HTML width attribute', () => { + const { columnWidths } = layoutFor( + ` + + +
AB
`, + { contentWidth: 300, forceStretch: false } + ); + expect(columnWidths).toEqual([150, 150]); + }); + it('should let cell content make a declared column wider', () => { const { columnWidths } = layoutFor( ` @@ -104,7 +116,10 @@ describe('TableLayout', () => {
Alongword
`, - { contentWidth: 300, forceStretch: false } + // The character-width estimate is pinned so the premise of the test — a + // column whose min-content exceeds its 20% share of 300px — holds + // whatever the computer defaults to. + { contentWidth: 300, forceStretch: false, baseFontCoeff: 0.65 } ); expect(columnWidths[1]).toBeGreaterThan(60); expect(totalWidth).toBeCloseTo(300); @@ -126,6 +141,182 @@ describe('TableLayout', () => { expect(totalWidth).toBeCloseTo(300); }); + it('should ignore col declarations beyond the last column of the grid', () => { + // A span of ten over two cells used to conjure eight columns nothing is + // rendered into, widening the table to 600px and handing it a scroller. + const { columnWidths, totalWidth } = layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths).toHaveLength(2); + expect(totalWidth).toBeLessThanOrEqual(400); + }); + + it('should let a col width override the width of its colgroup', () => { + // A `col` overrides its group rather than competing with it: taking the + // greater of the two widened the very column that asked to be narrower. + const { columnWidths } = layoutFor( + ` + + + + + +
AB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(100); + expect(columnWidths[1]).toBeCloseTo(300); + }); + + it('should let an absolute col width override a percentage colgroup width', () => { + // The two sizing classes used to be merged independently, so the group + // percentage survived the absolute width the column declared instead of it. + const { columnWidths } = layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(100); + }); + + it('should let a percentage col width override an absolute colgroup width', () => { + const { columnWidths } = layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(100); + }); + + it('should keep a colgroup width when its col declares only a min-width', () => { + // A `min-width` is a bound, not a declaration: it used to be stored in the + // same field as a width and so discarded the width of the group entirely. + const { columnWidths } = layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(200); + }); + + it('should cap a column that declares only a max-width', () => { + // `max-width` on an auto-width column used to be dropped, letting the + // column take the whole surplus of a stretched table. + const { columnWidths, totalWidth } = layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: true } + ); + expect(columnWidths[0]).toBeLessThanOrEqual(50); + expect(totalWidth).toBeCloseTo(400); + }); + + it('should pass a surplus no auto column can take to its neighbours', () => { + // Every auto column held at its own `max-width` used to drop the rest of + // the stretch surplus, leaving the table short of the width it was told to + // fill even though a neighbour had room to take it. + const { columnWidths, totalWidth } = layoutFor( + ` + + + + + +
AB
`, + { contentWidth: 400, forceStretch: true } + ); + expect(columnWidths[0]).toBeLessThanOrEqual(50); + expect(totalWidth).toBeCloseTo(400); + }); + + it('should pass a surplus no auto column can take to a percentage column', () => { + const { columnWidths, totalWidth } = layoutFor( + ` + + + + + +
AB
`, + { contentWidth: 400, forceStretch: true } + ); + expect(columnWidths[0]).toBeLessThanOrEqual(50); + expect(columnWidths[1]).toBeCloseTo(350); + expect(totalWidth).toBeCloseTo(400); + }); + + it('should stay narrower than its width when every column is capped', () => { + // Handing the surplus on stops at the last column that has room: none of + // these may grow, so the table ends up narrower than the width it was + // given rather than pushing a column past the ceiling it declared. + const { totalWidth } = layoutFor( + ` + + + + + +
AB
`, + { contentWidth: 400, forceStretch: true } + ); + expect(totalWidth).toBeCloseTo(100); + }); + + it('should cap a percentage column at its max-width in the min-width pass', () => { + // The declared widths were resolved against the first guess at the table + // width and reused verbatim once the `min-width` floor took over, so the + // max-width cap was rescaled against a width it never applied to. + const { columnWidths, totalWidth } = layoutFor( + ` + + +
AB
`, + { contentWidth: 600, forceStretch: false } + ); + expect(totalWidth).toBeCloseTo(300); + expect(columnWidths[0]).toBeCloseTo(100); + }); + + it('should cap a percentage column at an absolute max-width', () => { + const { columnWidths } = layoutFor( + ` + + + + + +
AB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(100); + }); + + it('should cap an absolute column width at a percentage max-width', () => { + // A percentage `max-width` was only ever compared with a percentage width, + // so it was silently dropped on a column sized in pixels — the two sizing + // classes disagreed on the very same declaration. + const { columnWidths } = layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(100); + }); + it('should keep a column holding only an image', () => { // An image contributes no text, so a text-derived maximum of zero used to // clamp this column away entirely. @@ -250,6 +441,97 @@ describe('TableLayout', () => { expect(totalWidth).toBeLessThan(400); }); + it('should stretch a table that only declares a min-width', () => { + // `min-width` is a floor, not a declared width: reading it as one made + // the table 200px wide inside a 600px container. + const { totalWidth } = layoutFor( + `${rows}
`, + { contentWidth: 600 } + ); + expect(totalWidth).toBeCloseTo(600); + }); + + it('should not stretch a table past its max-width', () => { + const { totalWidth } = layoutFor( + `${rows}
`, + { contentWidth: 600 } + ); + expect(totalWidth).toBeCloseTo(300); + }); + + it('should keep a shrink-to-fit table at its min-width', () => { + // Shrinking to fit still may not cross the floor the table asked for. + const { totalWidth } = layoutFor( + `${rows}
`, + { contentWidth: 600, forceStretch: false } + ); + expect(totalWidth).toBeCloseTo(300); + }); + + it('should not narrow a table by raising its min-width', () => { + // Laying the columns out against the floor resolves the percentage + // column against a *smaller* width, and the capped auto column cannot + // take up the slack. A floor may only widen the table. + const cols = ` + + + `; + const body = `${cols}AB`; + const { totalWidth: without } = layoutFor( + `${body}
`, + { contentWidth: 600, forceStretch: false } + ); + const { totalWidth: with400 } = layoutFor( + `${body}
`, + { contentWidth: 600, forceStretch: false } + ); + expect(with400).toBeGreaterThanOrEqual(without); + }); + + it('should scroll the columns that overflow the table max-width', () => { + // The cells demand 600px inside a table that paints only 300px, so the + // surplus belongs to a horizontal scroller rather than spilling out. + const { totalWidth, assignableWidth } = layoutFor( + ` + +
AB
`, + { contentWidth: 600, forceStretch: false } + ); + expect(assignableWidth).toBe(300); + expect(totalWidth).toBeGreaterThanOrEqual(600); + expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(true); + }); + + it('should not paint a table wider than the room its container leaves', () => { + // The insets were added back after the assignable width had been + // floored at zero, so a table whose padding alone overflows its + // container painted a box wider than the room it was given. + const { usedWidth, assignableWidth } = layoutFor( + `
+
A
+
`, + { contentWidth: 400, forceStretch: true } + ); + expect(assignableWidth).toBe(0); + expect(usedWidth).toBe(30); + }); + + it('should not paint a table past its own max-width', () => { + const { usedWidth } = layoutFor( + '
A
', + { contentWidth: 400, forceStretch: true } + ); + expect(usedWidth).toBe(10); + }); + + it('should shrink a table below its max-width when the content is narrow', () => { + const { totalWidth } = layoutFor( + `${rows}
`, + { contentWidth: 600, forceStretch: false } + ); + expect(totalWidth).toBeLessThan(300); + }); + it('should still overflow when the minimum widths do not fit', () => { const { totalWidth, assignableWidth } = layoutFor( `
diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts index 383b0bc..2b9e14d 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts @@ -98,4 +98,27 @@ describe('resolveAvailableWidth', () => { ) ).toBe(400); }); + + it('should not let an ancestor min-width narrow the available width', () => { + // `min-width` is a floor, not a width: an ancestor asking for *at least* + // 100px still hands its children the whole 400px it was given. Reading it + // as a declared width squeezed every descendant table to min-content. + expect( + availableWidthFor( + '
A
', + 400 + ) + ).toBe(400); + }); + + it('should raise a narrow ancestor up to its min-width', () => { + expect( + availableWidthFor( + `
+
A
+
`, + 400 + ) + ).toBe(100); + }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts index a12fb1c..b4700f4 100644 --- a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts @@ -1,6 +1,10 @@ import { Display, TColumnConstraints } from '../shared-types'; import reduceColumnConstraints from './reduceColumnConstraints'; import type { DeclaredColumnWidth } from './extractColumnWidths'; +import { clampWidth, lesserBound } from './resolveWidth'; + +/** Below this many pixels a leftover is not worth another distribution pass. */ +const EPSILON = 1e-6; function mapMinWidths(constraints: TColumnConstraints[]): number[] { return constraints.map((c) => c.minWidth); @@ -67,22 +71,53 @@ function normalizePercentages( return percentages; } +/** + * Grow the columns at `indexes` by `total`, never past a column's own + * `max-width`. + * + * @remarks + * Space a capped column cannot take is offered to the others, and space none + * of them can take is left unassigned: a table whose every column is capped + * ends up narrower than the width it was given, as it would in CSS, rather + * than pushing a column past the ceiling it declared. + */ function addDistributedWidth( widths: number[], total: number, - indexes: number[] + indexes: number[], + caps: Array ): number[] { if (indexes.length === 0 || total <= 0) { return widths; } - const weights = indexes.map((i) => widths[i] ?? 0); - const shares = distribute(total, weights); - return widths.map((width, i) => { - const candidateIndex = indexes.indexOf(i); - return candidateIndex === -1 - ? width - : width + (shares[candidateIndex] ?? 0); - }); + const result = [...widths]; + const hasRoom = (i: number) => { + const cap = caps[i]; + return cap == null || (result[i] ?? 0) < cap; + }; + let candidates = indexes.filter(hasRoom); + let remaining = total; + while (remaining > EPSILON && candidates.length > 0) { + const shares = distribute( + remaining, + candidates.map((i) => result[i] ?? 0) + ); + let consumed = 0; + candidates.forEach((i, k) => { + const cap = caps[i]; + const current = result[i] ?? 0; + const grown = current + (shares[k] ?? 0); + const used = cap == null ? grown : Math.min(grown, cap); + result[i] = used; + consumed += used - current; + }); + if (consumed <= EPSILON) { + break; + } + remaining -= consumed; + candidates = candidates.filter(hasRoom); + } + return result; } export default function computeColumnWidths( @@ -91,25 +126,58 @@ export default function computeColumnWidths( ): number[] { const contentWidth = display.contentWidth; const shouldStretch = !!display.forceStretch; + // The cell grid alone decides how many columns a table has. `col` and + // `colgroup` declarations past its last column describe columns that do not + // exist — honouring them would widen the table by the sum of widths nothing + // is ever rendered into, and hand it a scroll view to hold the surplus. const columnConstraints = reduceColumnConstraints(display.cells); - const columnCount = Math.max(columnConstraints.length, declaredWidths.length); - for (let i = 0; i < columnCount; i++) { - const constraints = (columnConstraints[i] ??= { - minWidth: 0, - spread: 0, - contentDensity: 0 - }); - const declaredWidth = declaredWidths[i]?.minWidth; - if (declaredWidth != null && declaredWidth > 0) { - // Absolute column widths contribute to intrinsic minimum and preferred - // widths. Percentage widths remain unresolved until distribution below. - constraints.minWidth = Math.max(constraints.minWidth, declaredWidth); - constraints.spread = Math.max(constraints.spread, declaredWidth); - } - } if (columnConstraints.length === 0) { return []; } + // A `max-width` may be declared in either unit, and caps the column in + // whichever sizing class it ends up in. Percentage bounds travel unresolved + // so that the same declarations can be reused against another table width, + // and are turned into pixels here, once that width is known. + const caps = columnConstraints.map((_, i) => { + const declared = declaredWidths[i]; + if (!declared) { + return null; + } + return lesserBound( + declared.maxWidth, + declared.maxPercent === null ? null : declared.maxPercent * contentWidth + ); + }); + for (const [i, constraints] of columnConstraints.entries()) { + const declared = declaredWidths[i]; + if (!declared) { + continue; + } + const cap = caps[i] ?? null; + // Absolute column widths contribute to intrinsic minimum and preferred + // widths. Percentage widths remain unresolved until distribution below, + // and contribute only the absolute floor they were given. + const floor = + declared.percent === null + ? clampWidth( + declared.width ?? declared.minWidth, + declared.minWidth, + cap + ) + : declared.minWidth; + if (floor > 0) { + constraints.minWidth = Math.max(constraints.minWidth, floor); + constraints.spread = Math.max(constraints.spread, floor); + } + if (cap !== null) { + // A `max-width` caps how far a column may grow, but never below the + // width its own content needs to be legible at all. + constraints.spread = Math.max( + constraints.minWidth, + Math.min(constraints.spread, cap) + ); + } + } const minWidths = mapMinWidths(columnConstraints); const spreads = mapSpreads(columnConstraints); const sumOfMinWidths = sumOf(minWidths); @@ -129,9 +197,18 @@ export default function computeColumnWidths( ); const percentageGuess = minWidths.map((minWidth, i) => { const percent = percentages[i]; - return percent === null || percent === undefined - ? minWidth - : Math.max(minWidth, percent * contentWidth); + if (percent === null || percent === undefined) { + return minWidth; + } + // The fraction is resolved here rather than at extraction, so that the + // same declarations can be reused whenever the table is laid out again + // against another width. A `max-width` caps the share in the same pass. + const cap = caps[i]; + const preferred = percent * contentWidth; + return Math.max( + minWidth, + cap == null ? preferred : Math.min(preferred, cap) + ); }); const percentageGuessTotal = sumOf(percentageGuess); if (contentWidth <= percentageGuessTotal) { @@ -154,21 +231,26 @@ export default function computeColumnWidths( if (!shouldStretch) { return maxContentGuess; } - const leftover = contentWidth - maxContentGuessTotal; - const autoColumns = maxContentGuess - .map((_, i) => i) - .filter((i) => declaredWidths[i] == null); - if (autoColumns.length > 0) { - return addDistributedWidth(maxContentGuess, leftover, autoColumns); + const allColumns = maxContentGuess.map((_, i) => i); + // A column that declared a width of its own already has the width it asked + // for; the surplus belongs to the ones that left it to the table to decide. + const autoColumns = allColumns.filter((i) => { + const declared = declaredWidths[i]; + return !declared || (declared.width === null && declared.percent === null); + }); + const percentColumns = allColumns.filter((i) => percentages[i] != null); + // Each class of column is offered the surplus in turn, so that what one + // cannot take — every column in it held at its own `max-width` — falls + // through to the next rather than being dropped and leaving the table short + // of the width it was told to fill. Only when no column anywhere has room + // left does the table stay narrower than its assignable width. + let widths = maxContentGuess; + for (const group of [autoColumns, percentColumns, allColumns]) { + const leftover = contentWidth - sumOf(widths); + if (leftover <= EPSILON) { + break; + } + widths = addDistributedWidth(widths, leftover, group, caps); } - const percentColumns = percentages - .map((percent, i) => (percent == null ? -1 : i)) - .filter((i) => i >= 0); - return addDistributedWidth( - maxContentGuess, - leftover, - percentColumns.length > 0 - ? percentColumns - : maxContentGuess.map((_, i) => i) - ); + return widths; } diff --git a/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts index 6ede00f..885458e 100644 --- a/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts @@ -1,16 +1,42 @@ import { TNode } from '@native-html/render'; import { - clampWidth, - resolveAttributeSize, - resolveCssSize, + lesserBound, + resolveAttributeLength, resolvePercentage } from './resolveWidth'; +/** + * The width declarations a `col` or its `colgroup` contributes to one column. + * + * @remarks + * The properties are deliberately kept apart. A declared width and a bound + * are different things — conflating them lets a `min-width` on a column + * discard the width declared by its group — and a width and a fraction belong + * to different sizing classes, which `computeColumnWidths` reconciles only + * once it knows the width to assign. + * + * None of them is resolved against a containing width: a declaration is a + * property of the markup, not of the box the table happens to be laid out in, + * and one resolved against a first guess at the table width could not be + * reused for a second. + */ export interface DeclaredColumnWidth { - /** An absolute lower bound contributed by the column or column group. */ - minWidth: number; + /** + * An absolute width declared by the column or its group; `null` when the + * declaration is a percentage, or when there is none. + */ + width: number | null; /** A preferred fraction of the assignable table width. */ percent: number | null; + /** An absolute lower bound from `min-width`; `0` when none is declared. */ + minWidth: number; + /** An absolute upper bound from `max-width`. */ + maxWidth: number | null; + /** + * An upper bound from a percentage `max-width`, as a fraction of the + * assignable table width; `null` when there is none. + */ + maxPercent: number | null; } const MAX_SPAN = 1000; @@ -33,81 +59,92 @@ function appendWidth( } } +/** + * Combine the width of a `colgroup` with that of one of its `col` children. + * + * @remarks + * The column overrides its group rather than competing with it: a group width + * applies only to the columns that declare none of their own, so a 25% `col` + * inside a 50% `colgroup` is 25% wide — taking the greater of the two would + * let a group widen the very column that asked to be narrower. A declaration + * in either sizing class counts, so a percentage `col` replaces an absolute + * group width just as it would another percentage. + * + * Bounds are not declarations and do not override: the group box contains the + * column box, so both apply and the stricter one wins. + */ function mergeWidths( group: DeclaredColumnWidth | null, column: DeclaredColumnWidth | null ): DeclaredColumnWidth | null { if (!group) return column; if (!column) return group; + const columnDeclaresWidth = column.width !== null || column.percent !== null; return { - minWidth: Math.max(group.minWidth, column.minWidth), - percent: - group.percent === null - ? column.percent - : column.percent === null - ? group.percent - : Math.max(group.percent, column.percent) + width: columnDeclaresWidth ? column.width : group.width, + percent: columnDeclaresWidth ? column.percent : group.percent, + minWidth: Math.max(column.minWidth, group.minWidth), + maxWidth: lesserBound(column.maxWidth, group.maxWidth), + maxPercent: lesserBound(column.maxPercent, group.maxPercent) }; } /** - * Keep percentage widths unresolved. Browsers carry these as intrinsic - * percentage contributions and reconcile them during width distribution. + * A processed absolute length. Percentages arrive as strings and are read by + * `resolvePercentage` instead, which keeps them unresolved. */ -function resolveColumnWidth( - tnode: TNode, - containingWidth: number -): DeclaredColumnWidth | null { +function absoluteSize(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : null; +} + +function resolveColumnWidth(tnode: TNode): DeclaredColumnWidth | null { const style = tnode.styles.nativeBlockRet; const cssPercent = resolvePercentage(style.width); - const cssAbsolute = resolveCssSize(style.width, containingWidth); - const hasCssWidth = cssPercent !== null || cssAbsolute !== null; - const attributePercent = hasCssWidth - ? null - : resolvePercentage(tnode.attributes.width); - const percent = cssPercent ?? attributePercent; - // Percentage min-width does not contribute to table-internal percentage - // sizing. Keep only an absolute lower bound here. - const absoluteMin = - typeof style.minWidth === 'number' && Number.isFinite(style.minWidth) - ? Math.max(0, style.minWidth) - : 0; - if (percent !== null) { - const percentageMax = resolvePercentage(style.maxWidth); - return { - minWidth: absoluteMin, - percent: - percentageMax === null ? percent : Math.min(percent, percentageMax) - }; - } - const absolute = - cssAbsolute ?? - (hasCssWidth + const cssAbsolute = absoluteSize(style.width); + // `auto` is still a CSS width declaration and therefore suppresses the + // lower-priority HTML presentational hint, despite contributing no size. + const hasCssWidth = style.width != null; + // The presentational attribute stands in for a missing CSS width, in + // whichever unit it is written. + const declaredPercent = + cssPercent ?? + (hasCssWidth ? null : resolvePercentage(tnode.attributes.width)); + // A percentage `min-width` contributes nothing to table-internal percentage + // sizing, so only an absolute lower bound is kept. A percentage `max-width`, + // on the other hand, caps the declared fraction in its own unit and travels + // on as a fraction, so that it also caps an absolute width once + // `computeColumnWidths` knows the width to resolve it against. + const minWidth = absoluteSize(style.minWidth) ?? 0; + const maxWidth = absoluteSize(style.maxWidth); + const maxPercent = resolvePercentage(style.maxWidth); + const percent = + declaredPercent === null ? null - : resolveAttributeSize(tnode.attributes.width, containingWidth)); - if (absolute === null && absoluteMin === 0) { + : Math.min(declaredPercent, maxPercent ?? declaredPercent); + const width = + percent !== null + ? null + : (cssAbsolute ?? + (hasCssWidth ? null : resolveAttributeLength(tnode.attributes.width))); + if ( + width === null && + percent === null && + minWidth === 0 && + maxWidth === null && + maxPercent === null + ) { return null; } - const absoluteMax = - typeof style.maxWidth === 'number' && Number.isFinite(style.maxWidth) - ? Math.max(0, style.maxWidth) - : null; - return { - minWidth: clampWidth( - absolute ?? absoluteMin, - absoluteMin, - absoluteMax - ), - percent: null - }; + return { width, percent, minWidth, maxWidth, maxPercent }; } function appendColgroupWidths( widths: Array, - colgroup: TNode, - containingWidth: number + colgroup: TNode ) { - const groupWidth = resolveColumnWidth(colgroup, containingWidth); + const groupWidth = resolveColumnWidth(colgroup); const columns = colgroup.children.filter((child) => child.tagName === 'col'); if (columns.length === 0) { appendWidth(widths, groupWidth, parseSpan(colgroup.attributes.span)); @@ -116,7 +153,7 @@ function appendColgroupWidths( for (const column of columns) { appendWidth( widths, - mergeWidths(groupWidth, resolveColumnWidth(column, containingWidth)), + mergeWidths(groupWidth, resolveColumnWidth(column)), parseSpan(column.attributes.span) ); } @@ -124,17 +161,16 @@ function appendColgroupWidths( /** Collect the ordered widths declared by colgroup and col elements. */ export default function extractColumnWidths( - table: TNode, - containingWidth: number + table: TNode ): Array { const widths: Array = []; for (const child of table.children) { if (child.tagName === 'colgroup') { - appendColgroupWidths(widths, child, containingWidth); + appendColgroupWidths(widths, child); } else if (child.tagName === 'col') { appendWidth( widths, - resolveColumnWidth(child, containingWidth), + resolveColumnWidth(child), parseSpan(child.attributes.span) ); } diff --git a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts index 82b5bc3..ce91c84 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts @@ -1,23 +1,27 @@ import { TNode } from '@native-html/render'; import { getHorizontalInsets, getHorizontalMargins } from './measure'; -import { clampWidth, resolveCssSize, resolveNodeWidth } from './resolveWidth'; +import { clampWidth, resolveWidthConstraints } from './resolveWidth'; /** * The width `tnode` offers to a block-level child, i.e. its content box. */ function reduceToContentBox(tnode: TNode, containingWidth: number): number { const style = tnode.styles.nativeBlockRet; + const { width, minWidth, maxWidth } = resolveWidthConstraints( + tnode, + containingWidth + ); // A declared width is a border box in React Native, so it already accounts // for padding and border; an auto width fills the containing block, minus - // the margins that sit outside the box, and is still capped by `max-width`. - const declaredWidth = resolveNodeWidth(tnode, containingWidth); - const borderBox = - declaredWidth ?? - clampWidth( - containingWidth - getHorizontalMargins(style), - resolveCssSize(style.minWidth, containingWidth), - resolveCssSize(style.maxWidth, containingWidth) - ); + // the margins that sit outside the box. Either way `min-width` and + // `max-width` only bound the result: an ancestor asking for *at least* + // 100px still hands its children everything it was given, and must not + // squeeze them into that 100px. + const borderBox = clampWidth( + width ?? containingWidth - getHorizontalMargins(style), + minWidth, + maxWidth + ); return Math.max(0, borderBox - getHorizontalInsets(style)); } diff --git a/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts b/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts index 94ff528..4d54d64 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts @@ -29,6 +29,18 @@ export function resolvePercentage(value: unknown): number | null { return percentage ? Number(percentage[1]) / 100 : null; } +/** + * Resolve the unitless form of an HTML presentational width attribute, the + * only one that needs no containing block to make sense of. + */ +export function resolveAttributeLength(value: unknown): number | null { + if (typeof value !== 'string') { + return null; + } + const unitless = UNITLESS_REGEX.exec(value.trim()); + return unitless ? Number(unitless[1]) : null; +} + /** Resolve an HTML presentational width attribute. */ export function resolveAttributeSize( value: unknown, @@ -37,13 +49,11 @@ export function resolveAttributeSize( if (typeof value !== 'string') { return null; } - const trimmed = value.trim(); - const percentage = PERCENTAGE_REGEX.exec(trimmed); + const percentage = PERCENTAGE_REGEX.exec(value.trim()); if (percentage) { return (containingWidth * Number(percentage[1])) / 100; } - const unitless = UNITLESS_REGEX.exec(trimmed); - return unitless ? Number(unitless[1]) : null; + return resolveAttributeLength(value); } /** Apply the CSS max-width, then min-width clamping order. */ @@ -62,20 +72,91 @@ export function clampWidth( return used; } +/** The stricter of two upper bounds, either of which may be absent. */ +export function lesserBound(a: number | null, b: number | null): number | null { + if (a === null) return b; + if (b === null) return a; + return Math.min(a, b); +} + +export interface WidthConstraints { + /** + * The width specified by the element, resolved against its containing block; + * `null` when the width is `auto`. + */ + width: number | null; + minWidth: number | null; + maxWidth: number | null; +} + +interface ResolveWidthOptions { + /** + * Percentage sizes do not impose an intrinsic width on a descendant whose + * containing block has not been sized yet. + */ + resolvePercentages?: boolean; +} + /** - * Resolve the width imposed by an element. CSS wins over the HTML width hint; - * min/max-width clamp it using normal CSS precedence. + * Resolve the three width properties of an element independently. + * + * @remarks + * They are kept apart on purpose. A `min-width` is a floor and a `max-width` a + * ceiling on whatever width the element ends up using — neither is itself a + * declared width, and treating one as such makes an element take the width of + * its own bound rather than the width it was offered. Callers combine them + * with {@link clampWidth} once they know the width to clamp. + * + * CSS wins over the HTML `width` hint, which is consulted last as befits a + * presentational attribute. */ -export function resolveNodeWidth( +export function resolveWidthConstraints( tnode: TNode, - containingWidth: number -): number | null { + containingWidth: number, + { resolvePercentages = true }: ResolveWidthOptions = {} +): WidthConstraints { const blockStyle = tnode.styles.nativeBlockRet; - const minWidth = resolveCssSize(blockStyle.minWidth, containingWidth); - const maxWidth = resolveCssSize(blockStyle.maxWidth, containingWidth); - const cssWidth = resolveCssSize(blockStyle.width, containingWidth); - const width = - cssWidth ?? resolveAttributeSize(tnode.attributes.width, containingWidth); + const resolveCss = (value: unknown) => + resolvePercentages || typeof value === 'number' + ? resolveCssSize(value, containingWidth) + : null; + const resolveAttribute = (value: unknown) => + resolvePercentages + ? resolveAttributeSize(value, containingWidth) + : resolveAttributeLength(value); + // A presentational width is a lowest-priority CSS hint. It only participates + // when CSS supplied no width declaration at all; an explicit `width:auto` + // still wins even though it resolves to no numeric width here. + const hasCssWidth = blockStyle.width != null; + return { + width: hasCssWidth + ? resolveCss(blockStyle.width) + : resolveAttribute(tnode.attributes.width), + minWidth: resolveCss(blockStyle.minWidth), + maxWidth: resolveCss(blockStyle.maxWidth) + }; +} + +/** + * The width an element *imposes* on the box that holds it, or `null` when it + * imposes none. + * + * @remarks + * A lone `min-width` counts here, unlike when resolving the width an element + * will be *given*: a block demanding at least 200px makes its container at + * least that wide even with an `auto` width. This is the bound a table cell + * needs from its contents, not a width the block itself takes. + */ +export function resolveImposedWidth( + tnode: TNode, + containingWidth: number, + options?: ResolveWidthOptions +): number | null { + const { width, minWidth, maxWidth } = resolveWidthConstraints( + tnode, + containingWidth, + options + ); if (width === null && minWidth === null) { return null; } diff --git a/packages/heuristic-table-plugin/src/index.ts b/packages/heuristic-table-plugin/src/index.ts index a2536f2..b48cf58 100644 --- a/packages/heuristic-table-plugin/src/index.ts +++ b/packages/heuristic-table-plugin/src/index.ts @@ -18,6 +18,11 @@ export { TableRoot } from './shared-types'; +export { + DEFAULT_FONT_WEIGHT_COEFFS, + FontWeightCoefficients +} from './helpers/TCellConstraintsComputer'; + export { TableRenderer, ThRenderer, TdRenderer, colgroupModel }; /** diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 5818cd5..0694b89 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -6,6 +6,7 @@ import { TNode } from '@native-html/render'; import TableLayout from './TableLayout'; +import type { FontWeightCoefficients } from './helpers/TCellConstraintsComputer'; /** * @public @@ -144,6 +145,33 @@ export interface Settings { * When true, force the table to stretch to the available width. */ forceStretch?: boolean; + /** + * The average advance width of one character, as a fraction of the font + * size, used to estimate how wide a cell's text is. + * + * @remarks + * Text is never measured, only estimated: a cell's bounds are its character + * count times this coefficient times the font size. Raise it when tables + * come out too narrow and their text wraps more than it should, lower it + * when cells claim more width than their content occupies. + * + * @defaultValue 0.65 + */ + baseFontCoeff?: number; + /** + * How much wider text renders at a given font weight than at a regular one, + * keyed by the stringified `fontWeight`. + * + * @remarks + * Merged over the defaults rather than replacing them, so `{ bold: 1.05 }` + * retunes bold text alone and leaves the numeric weights as they were. A + * weight with no entry, before or after merging, costs nothing. Pass a + * referentially stable object — a fresh literal on every render relays out + * every table using it. + * + * @defaultValue \{ normal: 1, bold: 1.3, '100': 0.8 … '900': 1.5 \} + */ + fontWeightCoeffs?: FontWeightCoefficients; /** * Available width at the root of the render tree, prior to scrolling. * @@ -177,6 +205,33 @@ export interface HeuristicTablePluginConfig { * @defaultValue true */ forceStretch?: boolean; + /** + * The average advance width of one character, as a fraction of the font + * size, used to estimate how wide a cell's text is. + * + * @remarks + * Text is never measured, only estimated: a cell's bounds are its character + * count times this coefficient times the font size. Raise it when tables + * come out too narrow and their text wraps more than it should, lower it + * when cells claim more width than their content occupies. + * + * @defaultValue 0.65 + */ + baseFontCoeff?: number; + /** + * How much wider text renders at a given font weight than at a regular one, + * keyed by the stringified `fontWeight`. + * + * @remarks + * Merged over the defaults rather than replacing them, so `{ bold: 1.05 }` + * retunes bold text alone and leaves the numeric weights as they were. A + * weight with no entry, before or after merging, costs nothing. Pass a + * referentially stable object — a fresh literal on every render relays out + * every table using it. + * + * @defaultValue \{ normal: 1, bold: 1.3, '100': 0.8 … '900': 1.5 \} + */ + fontWeightCoeffs?: FontWeightCoefficients; /** * Customize cells appearance with this function. * diff --git a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts index 53db8c4..15d9215 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts @@ -42,14 +42,21 @@ export default function useHtmlTableProps( ): HTMLTableProps { const table = useRendererProps('table'); const forceStretch = table?.forceStretch; + const baseFontCoeff = table?.baseFontCoeff; + const fontWeightCoeffs = table?.fontWeightCoeffs; const sharedContentWidth = useContentWidth(); const contentWidth = typeof options.overrideContentWidth === 'number' ? options.overrideContentWidth : sharedContentWidth; const settings = useMemo( - () => ({ contentWidth, forceStretch }), - [contentWidth, forceStretch] + () => ({ + contentWidth, + forceStretch, + baseFontCoeff, + fontWeightCoeffs + }), + [contentWidth, forceStretch, baseFontCoeff, fontWeightCoeffs] ); const layout = useTableLayout({ tnode, settings }); return { diff --git a/packages/table-plugin/src/HTMLTable.tsx b/packages/table-plugin/src/HTMLTable.tsx index 6d2b8fd..99d1812 100644 --- a/packages/table-plugin/src/HTMLTable.tsx +++ b/packages/table-plugin/src/HTMLTable.tsx @@ -84,7 +84,6 @@ function findHeight({ computeHeuristicContentHeight: (tableStats: HTMLTableStats) => number; contentHeight: number | null; } & HTMLTableStats) { - console.log('findHeight'); if (typeof contentHeight === 'number') { return computeContainerHeight({ type: 'accurate', @@ -138,15 +137,6 @@ function useAnimatedAutoheight({ webshellProps: webViewProps as any, resetHeightOnViewportWidthChange: false }); - console.log({ - computeContainerHeight, - computeHeuristicContentHeight, - 'contentSize.height': contentSize.height, - syncState, - numOfChars, - numOfColumns, - numOfRows - }); const containerHeight = useMemo( () => findHeight({ From 4237ad303796bd61d93e987c835338962fbba09b Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Wed, 9 Sep 2026 15:08:01 +0200 Subject: [PATCH 05/21] feat(heuristic-table-plugin): implement border collapsing and stick to default styles defined --- .../heuristic-table-plugin/src/HTMLTable.tsx | 48 ++- .../heuristic-table-plugin/src/TableLayout.ts | 79 +++- .../src/TreeRenderer.tsx | 28 +- .../src/helpers/TCellConstraintsComputer.ts | 42 +- .../TCellConstraintsComputer.test.ts | 20 + .../__tests__/createRenderTree.test.ts | 4 +- .../__tests__/fillTableDisplay.test.ts | 6 + .../src/helpers/__tests__/tableStyles.test.ts | 295 ++++++++++++++ .../src/helpers/createRenderTree.ts | 19 +- .../src/helpers/fillTableDisplay.ts | 57 ++- .../src/helpers/tableStyles.ts | 369 ++++++++++++++++++ .../src/shared-types.ts | 11 + .../src/useHtmlTableCellProps.ts | 64 ++- .../src/useHtmlTableProps.ts | 12 +- 14 files changed, 997 insertions(+), 57 deletions(-) create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/tableStyles.ts diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index 49c75d2..da94e50 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -1,9 +1,9 @@ -import React, { memo, PropsWithChildren } from 'react'; +import React, { memo, PropsWithChildren, useMemo } from 'react'; import { ScrollView, View } from 'react-native'; import TreeRenderer from './TreeRenderer'; import { HTMLTableProps } from './shared-types'; -import { getHorizontalInsets } from './helpers/measure'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; +import { getCollapsedTableBorderStyle } from './helpers/tableStyles'; export function shouldScrollTable( tableWidth: number, @@ -56,7 +56,33 @@ const HTMLTable = memo(function HTMLTable({ // it, which is what `contentWidth` would be if it were narrowed on the way // down the tree. Sizing the container off `settings.contentWidth` instead // would spill the table out of every padded ancestor it sits in. - const insets = getHorizontalInsets(props.tnode.styles.nativeBlockRet); + const insets = layout.horizontalInsets; + const getStyleForCell = config.getStyleForCell; + // `getStyleForCell` is handed cells the layout only produces at the end of + // its own work, so the outer collapsed edge is narrowed once more here, now + // that every border the edge cells actually paint is known. Leaving it to + // the layout alone would let a border only the config declares lose to the + // weaker one source CSS resolved, and be painted by neither. The insets the + // layout measured against still come from source CSS: a config border + // changes what the table paints, not how wide it was laid out. + const tableBorderStyle = useMemo( + () => + layout.borderCollapse && getStyleForCell + ? getCollapsedTableBorderStyle( + { + cells: layout.cells, + maxX: layout.display.maxX, + maxY: layout.display.maxY + }, + layout.tableBorderStyle ?? {}, + (cell) => ({ + ...cell.tnode.styles.nativeBlockRet, + ...getStyleForCell(cell) + }) + ) + : layout.tableBorderStyle, + [getStyleForCell, layout] + ); return ( + }} + > + availableWidth={layout.assignableWidth} + > {React.createElement(TreeRenderer, { node: layout.renderTree, config, + borderCollapse: layout.borderCollapse, + // Cells need the edge the wrapper resolved, not just their position + // in the matrix: an outer boundary it leaves bare is still theirs. + tableBorderStyle, + maxX: layout.display.maxX, + maxY: layout.display.maxY, renderIndex: props.renderIndex, renderLength: props.renderLength })} diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 2705245..7f3a9f4 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -1,16 +1,22 @@ import { sum } from 'ramda'; +import { ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; import computeColumnWidths from './helpers/computeColumnWidths'; -import createRenderTree from './helpers/createRenderTree'; +import createRenderTree, { makeTableCells } from './helpers/createRenderTree'; import fillTableDisplay, { - createEmptyDisplay + createEmptyDisplay, + measureDisplay } from './helpers/fillTableDisplay'; import TCellConstraintsComputer from './helpers/TCellConstraintsComputer'; -import { Display, Settings, TableRoot } from './shared-types'; +import { Display, Settings, TableCell, TableRoot } from './shared-types'; import extractColumnWidths from './helpers/extractColumnWidths'; import { clampWidth, resolveWidthConstraints } from './helpers/resolveWidth'; import resolveAvailableWidth from './helpers/resolveAvailableWidth'; import { getHorizontalInsets, getHorizontalMargins } from './helpers/measure'; +import { + getCollapsedTableBorderStyle, + resolveBorderCollapse +} from './helpers/tableStyles'; /** * Tables fill the width their containing block leaves them unless the config @@ -23,6 +29,9 @@ export default class TableLayout { public readonly display: Display; public readonly columnWidths: number[]; public readonly totalWidth: number; + public readonly borderCollapse: boolean; + public readonly tableBorderStyle: ViewStyle | null; + public readonly horizontalInsets: number; /** * The border-box width the table may occupy, after the horizontal spacing of * every ancestor and the table's own margins have been subtracted from @@ -47,11 +56,20 @@ export default class TableLayout { * its ancestors and its own `max-width` allow. */ public readonly usedWidth: number; + /** + * Every cell of the table, at the width the columns resolved to. + * + * @remarks + * This is what {@link HeuristicTablePluginConfig.getStyleForCell} is called + * with, so it is the earliest point at which the styles that function + * contributes can take part in the collapsing border model. + */ + public readonly cells: TableCell[]; public readonly renderTree: TableRoot; constructor(tnode: TNode, config: Settings) { const style = tnode.styles.nativeBlockRet; + this.borderCollapse = resolveBorderCollapse(tnode, config.borderCollapse); const containingWidth = resolveAvailableWidth(tnode, config.contentWidth); - const insets = getHorizontalInsets(style); const availableWidth = Math.max( 0, containingWidth - getHorizontalMargins(style) @@ -73,6 +91,30 @@ export default class TableLayout { minWidth, maxWidth ); + const forceStretch = + (config.forceStretch ?? DEFAULT_FORCE_STRETCH) || + declaredTableWidth !== null; + // Cell coordinates and spans do not depend on the width the table resolves + // to; only their constraints do, and measuring text is the costly half of + // a layout pass. Laying the grid out first lets the collapsing model + // resolve the table's own borders — which feed the insets the cells are + // then measured against — without a second pass over the matrix. + const display = createEmptyDisplay({ + ...config, + // A table with a specified width distributes that width over its + // columns; shrink-to-fit only applies when the table width is auto, + // and is opt-in. + forceStretch + }); + fillTableDisplay(tnode, display); + this.tableBorderStyle = this.borderCollapse + ? getCollapsedTableBorderStyle(display, style) + : null; + const effectiveTableStyle = this.tableBorderStyle + ? { ...style, ...this.tableBorderStyle } + : style; + const insets = getHorizontalInsets(effectiveTableStyle); + this.horizontalInsets = insets; this.availableWidth = availableWidth; // A table capped by `max-width` — or one whose declared width is narrower // than its content demands — offers its columns less room than its @@ -81,22 +123,16 @@ export default class TableLayout { this.usedWidth = Math.max(0, Math.min(usedTableWidth, availableWidth)); this.assignableWidth = Math.max(0, this.usedWidth - insets); const layoutContentWidth = Math.max(0, usedTableWidth - insets); - const layoutSettings = { - ...config, - contentWidth: layoutContentWidth, - // A table with a specified width distributes that width over its columns; - // shrink-to-fit only applies when the table width is auto, and is opt-in. - forceStretch: - (config.forceStretch ?? DEFAULT_FORCE_STRETCH) || - declaredTableWidth !== null - }; - const computer = new TCellConstraintsComputer({ - contentWidth: layoutContentWidth, - baseFontCoeff: config.baseFontCoeff, - fontWeightCoeffs: config.fontWeightCoeffs - }); - this.display = createEmptyDisplay(layoutSettings); - fillTableDisplay(tnode, this.display, computer); + display.contentWidth = layoutContentWidth; + measureDisplay( + display, + new TCellConstraintsComputer({ + contentWidth: layoutContentWidth, + baseFontCoeff: config.baseFontCoeff, + fontWeightCoeffs: config.fontWeightCoeffs + }) + ); + this.display = display; // Declared column widths are independent of the width they will be // resolved against, so the same set serves the min-width pass below. const declaredColumnWidths = extractColumnWidths(tnode); @@ -120,6 +156,7 @@ export default class TableLayout { } this.columnWidths = columnWidths; this.totalWidth = sum(columnWidths); - this.renderTree = createRenderTree(this.display, this.columnWidths); + this.cells = makeTableCells(this.display, this.columnWidths); + this.renderTree = createRenderTree(this.cells); } } diff --git a/packages/heuristic-table-plugin/src/TreeRenderer.tsx b/packages/heuristic-table-plugin/src/TreeRenderer.tsx index 5a99548..8b6f0dc 100644 --- a/packages/heuristic-table-plugin/src/TreeRenderer.tsx +++ b/packages/heuristic-table-plugin/src/TreeRenderer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, View, ViewStyle } from 'react-native'; import { TNodeRenderer } from '@native-html/render'; import { HeuristicTablePluginConfig, TableRenderNode } from './shared-types'; @@ -11,6 +11,10 @@ const styles = StyleSheet.create({ export default function TreeRenderer({ node, config, + borderCollapse, + tableBorderStyle, + maxX, + maxY, renderIndex, renderLength }: { @@ -18,6 +22,10 @@ export default function TreeRenderer({ renderIndex: number; renderLength: number; config?: HeuristicTablePluginConfig; + borderCollapse: boolean; + tableBorderStyle: ViewStyle | null; + maxX: number; + maxY: number; }) { if (node.type === 'cell') { return ( @@ -26,7 +34,15 @@ export default function TreeRenderer({ renderIndex={renderIndex} renderLength={renderLength} propsFromParent={ - { cell: node, collapsedMarginTop: null, config } as any + { + cell: node, + collapsedMarginTop: null, + config, + borderCollapse, + tableBorderStyle, + maxX, + maxY + } as any } tnode={node.tnode} /> @@ -39,6 +55,10 @@ export default function TreeRenderer({ node: v, key: i, config, + borderCollapse, + tableBorderStyle, + maxX, + maxY, renderIndex: i, renderLength: node.children.length }) @@ -53,6 +73,10 @@ export default function TreeRenderer({ node: v, key: i, config, + borderCollapse, + tableBorderStyle, + maxX, + maxY, renderIndex: i, renderLength: node.children.length }) diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index d774cdc..32c0dcc 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -30,10 +30,16 @@ interface TCellStats { */ horizontalSpace: number; /** - * The maximum of explicit widths or min-widths of block elements in this - * cell, including margins. + * The maximum of explicit widths or min-widths of the block elements *inside* + * this cell, including margins. Content-box against the cell, so the cell's + * own horizontal spacing still has to be added on top. */ blockWidth: number; + /** + * The border-box width the cell itself declares, or `null` when it declares + * none. Already holds the cell's padding and border. + */ + cellBoxWidth: number | null; /** * Text stats in this cell. */ @@ -43,6 +49,7 @@ interface TCellStats { function getInitCellStatsForTnode(tnode: TNode): TCellStats { return { blockWidth: 0, + cellBoxWidth: null, horizontalSpace: getHorizontalSpacing(tnode.styles.nativeBlockRet), textStats: [] }; @@ -206,8 +213,19 @@ export default class TCellConstraintsComputer { if (tnode.type === 'block') { const width = this.resolveBlockWidth(tnode, isCellRoot); if (width !== null) { - const margins = getHorizontalMargins(tnode.styles.nativeBlockRet); - stats.blockWidth = Math.max(stats.blockWidth, width + margins); + if (isCellRoot) { + // React Native lays out with `box-sizing: border-box`, and CSS + // gives a table cell that same box model, so the width a cell + // declares already holds its padding and border. It is kept apart + // from the descendant widths below, which are content-box against + // the cell and so do have to grow by its spacing. Margins play no + // part either: a table cell has none, and the cell renderer zeroes + // whatever a stylesheet asked for. + stats.cellBoxWidth = width; + } else { + const margins = getHorizontalMargins(tnode.styles.nativeBlockRet); + stats.blockWidth = Math.max(stats.blockWidth, width + margins); + } } } tnode.children.forEach((n) => this.assembleCellStats(n, stats, false)); @@ -259,12 +277,18 @@ export default class TCellConstraintsComputer { // greater than MCW, W is the minimum cell width", and the maximum cell // width is likewise raised by the column 'width'. So an explicit width // lifts *both* bounds — never just one, or the cell would end up - // narrower than the width it asked for. - const minWidth = - Math.max(blockWidth, textConstrains.minWidth) + stats.horizontalSpace; - const maxWidth = + // narrower than the width it asked for. Being a border-box width, it + // bounds the spaced total rather than joining the content it holds. + const cellBoxWidth = stats.cellBoxWidth ?? 0; + const minWidth = Math.max( + Math.max(blockWidth, textConstrains.minWidth) + stats.horizontalSpace, + cellBoxWidth + ); + const maxWidth = Math.max( Math.max(blockWidth, textConstrains.contentDensity) + - stats.horizontalSpace; + stats.horizontalSpace, + cellBoxWidth + ); return { minWidth, // `max-width` caps the width the cell would *like*, but never takes it diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index e4176a5..d983c5b 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -145,6 +145,26 @@ describe('TCellConstraintsComputer', () => { expect(minWidth).toBeLessThan(220); }); + it('should treat a declared cell width as a border-box one', () => { + // React Native lays out with `box-sizing: border-box`, and so does CSS + // for a table cell: the padding sits inside the 200px, it does not + // widen the column to 216px. + const { minWidth, maxWidth } = constraintsFor( + 'a' + ); + expect(minWidth).toBe(200); + expect(maxWidth).toBe(200); + }); + + it('should still add cell spacing to a descendant width', () => { + // A block inside the cell is content-box against it, so the cell has to + // grow by its own padding to hold the 200px the block asked for. + const { minWidth } = constraintsFor( + '
' + ); + expect(minWidth).toBe(216); + }); + it('should read the presentational width attribute', () => { const { minWidth } = constraintsFor('a'); expect(minWidth).toBeGreaterThanOrEqual(200); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts index 50937dc..ceb892f 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts @@ -1,6 +1,6 @@ import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; import { createTableTNode } from './utils'; -import createRenderTree from '../createRenderTree'; +import createRenderTree, { makeTableCells } from '../createRenderTree'; import TCellConstraintsComputer from '../TCellConstraintsComputer'; import { TableCell, @@ -14,7 +14,7 @@ function makeRenderTree(html: string, columnWidths: number[]) { const display = createEmptyDisplay({ contentWidth: 1000 }); const computer = new TCellConstraintsComputer({}); fillTableDisplay(tnode, display, computer); - return createRenderTree(display, columnWidths); + return createRenderTree(makeTableCells(display, columnWidths)); } function rowContainer( diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts index 5950823..b20e690 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts @@ -100,6 +100,12 @@ describe('fillTableDisplay', () => { } ]); }); + it('should include a final colspan in the maximum column index', () => { + const tnode = createTableTNode(` +
AB
+ `); + expect(createDisplay(tnode).maxX).toBe(2); + }); it('should take rowspan into account to compute cell coordinates (x=0)', () => { const table = ` diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts new file mode 100644 index 0000000..00f77ff --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts @@ -0,0 +1,295 @@ +import { + getCollapsedCellBorderStyle, + getCollapsedTableBorderStyle, + resolveBorderCollapse, + resolveCellVerticalAlign +} from '../tableStyles'; +import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; +import { createTableTNode } from './utils'; + +function findCell(html: string, x = 0) { + const table = createTableTNode(html); + const cells = [] as typeof table.children; + const visit = (node: (typeof table.children)[number]) => { + if (node.tagName === 'td' || node.tagName === 'th') { + cells.push(node); + } else { + node.children.forEach(visit); + } + }; + table.children.forEach(visit); + return cells[x]; +} + +/** A wrapper that paints all four of its resolved outer edges. */ +const FRAMED = { + borderTopWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderLeftWidth: 1 +}; + +function displayFor(html: string) { + const table = createTableTNode(html); + const display = createEmptyDisplay({ contentWidth: 400 }); + fillTableDisplay(table, display); + return { display, table }; +} + +describe('table styles', () => { + describe('vertical alignment', () => { + it('declares nothing when the cell inherits the HTML default', () => { + expect( + resolveCellVerticalAlign(findCell('
A
')) + ).toBeNull(); + }); + + it('honours inline cell alignment', () => { + expect( + resolveCellVerticalAlign( + findCell( + '
A
' + ) + ) + ).toBe('bottom'); + }); + + it('inherits inline row alignment', () => { + expect( + resolveCellVerticalAlign( + findCell( + '
A
' + ) + ) + ).toBe('top'); + }); + + it('honours the legacy valign attribute', () => { + expect( + resolveCellVerticalAlign( + findCell('
A
') + ) + ).toBe('baseline'); + }); + }); + + describe('border collapse', () => { + it('keeps separate borders by default', () => { + const table = createTableTNode('
A
'); + expect(resolveBorderCollapse(table)).toBe(false); + }); + + it('honours an inline border-collapse declaration', () => { + const table = createTableTNode( + '
A
' + ); + expect(resolveBorderCollapse(table)).toBe(true); + }); + + it('allows renderer config to override inline CSS', () => { + const table = createTableTNode( + '
A
' + ); + expect(resolveBorderCollapse(table, 'separate')).toBe(false); + }); + + it('removes duplicate leading and top cell edges', () => { + // An interior cell keeps only the trailing and bottom halves it owns. + expect( + getCollapsedCellBorderStyle( + { x: 1, y: 1, lenX: 1, lenY: 1 }, + { borderWidth: 1, borderColor: 'black' }, + { maxX: 2, maxY: 2, tableBorderStyle: FRAMED } + ) + ).toEqual({ + borderLeftWidth: 0, + borderTopWidth: 0, + borderRightWidth: 1, + borderRightColor: 'black', + borderBottomWidth: 1, + borderBottomColor: 'black' + }); + }); + + it('lets the table wrapper own all resolved outside edges', () => { + expect( + getCollapsedCellBorderStyle( + { x: 0, y: 0, lenX: 1, lenY: 1 }, + { borderWidth: 1, borderColor: 'black' }, + { maxX: 0, maxY: 0, tableBorderStyle: FRAMED } + ) + ).toEqual({ + borderTopWidth: 0, + borderRightWidth: 0, + borderBottomWidth: 0, + borderLeftWidth: 0 + }); + }); + + it('rules off interior rows from a border-top-only cell', () => { + // The boundary below the cell is the same declaration as the one above + // the next row, and it is the only half this cell can paint. + expect( + getCollapsedCellBorderStyle( + { x: 0, y: 0, lenX: 1, lenY: 1 }, + { borderTopWidth: 2, borderTopColor: 'red' }, + { + maxX: 0, + maxY: 3, + tableBorderStyle: { ...FRAMED, borderBottomWidth: 0 } + } + ) + ).toMatchObject({ borderBottomWidth: 2, borderBottomColor: 'red' }); + }); + + it('rules off interior columns from a border-left-only cell', () => { + expect( + getCollapsedCellBorderStyle( + { x: 1, y: 0, lenX: 1, lenY: 1 }, + { borderLeftWidth: 2, borderLeftColor: 'red' }, + { maxX: 3, maxY: 0, tableBorderStyle: FRAMED } + ) + ).toMatchObject({ borderRightWidth: 2, borderRightColor: 'red' }); + }); + + it('keeps an outside edge the table wrapper does not paint', () => { + // A border a cell only gets from `getStyleForCell` is invisible to the + // wrapper resolution, so stripping it here would lose the frame. + expect( + getCollapsedCellBorderStyle( + { x: 0, y: 0, lenX: 1, lenY: 1 }, + { borderWidth: 1, borderColor: 'blue' }, + { + maxX: 0, + maxY: 0, + tableBorderStyle: { + borderTopWidth: 0, + borderRightWidth: 0, + borderBottomWidth: 0, + borderLeftWidth: 0 + } + } + ) + ).toEqual({ + borderTopWidth: 1, + borderTopColor: 'blue', + borderRightWidth: 1, + borderRightColor: 'blue', + borderBottomWidth: 1, + borderBottomColor: 'blue', + borderLeftWidth: 1, + borderLeftColor: 'blue' + }); + }); + + it('keeps the stronger half of an interior boundary', () => { + expect( + getCollapsedCellBorderStyle( + { x: 1, y: 1, lenX: 1, lenY: 1 }, + { + borderLeftWidth: 4, + borderLeftColor: 'red', + borderRightWidth: 1, + borderRightColor: 'blue' + }, + { maxX: 3, maxY: 3, tableBorderStyle: FRAMED } + ) + ).toMatchObject({ borderRightWidth: 4, borderRightColor: 'red' }); + }); + + it('promotes a stronger cell border to the outside table edge', () => { + const { display, table } = displayFor(` + + +
A
+ `); + expect( + getCollapsedTableBorderStyle(display, table.styles.nativeBlockRet) + ).toMatchObject({ + borderTopWidth: 1, + borderTopColor: 'black', + borderRightWidth: 1, + borderRightColor: 'black', + borderBottomWidth: 1, + borderBottomColor: 'black', + borderLeftWidth: 1, + borderLeftColor: 'black' + }); + }); + + it('clips a rowspan overrunning the last row to the bottom edge', () => { + // The table does not grow rows to fit an oversized `rowspan`, so the + // spanning cell sits at the bottom edge the wrapper painted rather than + // ruling off a row of its own underneath it. + expect( + getCollapsedCellBorderStyle( + { x: 0, y: 0, lenX: 1, lenY: 5 }, + { borderBottomWidth: 3, borderBottomColor: 'red' }, + { maxX: 1, maxY: 1, tableBorderStyle: FRAMED } + ) + ).toMatchObject({ borderBottomWidth: 0 }); + }); + + it('resolves the outside edge over the rows the table has', () => { + // A `rowspan` past the last row must not take the bottom edge with it: + // the cells of the last row still meet the wrapper there. + const { display, table } = displayFor(` + + + + + + +
AB
C
+ `); + expect(display.maxY).toBe(1); + expect( + getCollapsedTableBorderStyle(display, table.styles.nativeBlockRet) + ).toMatchObject({ borderBottomWidth: 5, borderBottomColor: 'blue' }); + }); + + it('weighs the styles a cell only gets from the config', () => { + // `getStyleForCell` is invisible to the source CSS resolution, so a + // border it declares would otherwise lose to the weaker table one and + // be painted by neither the wrapper nor the cell. + const { display, table } = displayFor(` + + +
A
+ `); + expect( + getCollapsedTableBorderStyle( + display, + table.styles.nativeBlockRet, + (cell) => ({ + ...cell.tnode.styles.nativeBlockRet, + borderWidth: 3, + borderColor: 'red' + }) + ) + ).toMatchObject({ + borderTopWidth: 3, + borderTopColor: 'red', + borderRightWidth: 3, + borderBottomWidth: 3, + borderLeftWidth: 3 + }); + }); + + it('gives an equal cell border precedence over the table color', () => { + const { display, table } = displayFor(` + + +
A
+ `); + expect( + getCollapsedTableBorderStyle(display, table.styles.nativeBlockRet) + ).toMatchObject({ + borderTopColor: 'blue', + borderRightColor: 'blue', + borderBottomColor: 'blue', + borderLeftColor: 'blue' + }); + }); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts b/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts index b4ba642..43d82aa 100644 --- a/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts +++ b/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts @@ -99,11 +99,22 @@ function makeCell(columnWidths: number[], cell: DisplayCell): TableCell { }; } -export default function createRenderTree( - display: Display, +/** + * Resolve the width of every cell of `display` from the column widths. + * + * @remarks + * Kept apart from {@link createRenderTree} because the flat list is also what + * {@link HeuristicTablePluginConfig.getStyleForCell} is called with: a cell + * only becomes a {@link TableCell} once its width exists. + */ +export function makeTableCells( + display: Pick, columnWidths: number[] -): TableRoot { - const cells = display.cells.map((cell) => makeCell(columnWidths, cell)); +): TableCell[] { + return display.cells.map((cell) => makeCell(columnWidths, cell)); +} + +export default function createRenderTree(cells: TableCell[]): TableRoot { const rows = makeRows(cells); const vGroups = groupCellsByVGroup(rows); const children = translateVGroups(vGroups); diff --git a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts index bc4de09..734588e 100644 --- a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts +++ b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts @@ -1,7 +1,27 @@ import { TNode } from '@native-html/render'; -import { Display, DisplayCell, Settings } from '../shared-types'; +import { + Display, + DisplayCell, + Settings, + TCellConstraints +} from '../shared-types'; import TCellConstraintsComputer from './TCellConstraintsComputer'; +/** + * The constraints of a cell no computer has measured yet. + * + * @remarks + * {@link fillTableDisplay} may be called without a computer, to lay the grid + * out before the width its cells must be measured against is known. Every cell + * of such a display carries this placeholder until {@link measureDisplay} + * replaces it. + */ +const UNMEASURED_CONSTRAINTS: TCellConstraints = Object.freeze({ + contentDensity: 0, + maxWidth: 0, + minWidth: 0 +}); + export function createEmptyDisplay(config: Settings): Display { return { offsetX: 0, @@ -60,10 +80,19 @@ function findFreeSlotX(display: Display, fromX: number, y: number): number { return x; } +/** + * Lay every `th` and `td` of `tnode` out on the matrix of `display`. + * + * @param computer - Measures each cell as it is laid down. Omit it to build + * the grid alone — coordinates and spans do not depend on the width the table + * resolves to, whereas constraints do, and measuring text is the costly half + * of a layout pass. Pass the display to {@link measureDisplay} once that width + * is known. + */ export default function fillTableDisplay( tnode: TNode, display: Display, - computer: TCellConstraintsComputer + computer?: TCellConstraintsComputer ) { if (tnode.tagName === 'tr') { display.maxY = display.maxY + 1; @@ -79,7 +108,9 @@ export default function fillTableDisplay( // column from `nodeIndex` instead would let a stray non-cell element // inside the row shift every following cell. const startX = findFreeSlotX(display, display.offsetX, startY); - const constraints = computer.computeCellConstraints(tnode); + const constraints = computer + ? computer.computeCellConstraints(tnode) + : UNMEASURED_CONSTRAINTS; const cell: DisplayCell = { lenX, lenY, @@ -100,10 +131,28 @@ export default function fillTableDisplay( } } } - display.maxX = Math.max(display.maxX, startX); + display.maxX = Math.max(display.maxX, startX + lenX - 1); } else { tnode.children.forEach((child) => fillTableDisplay(child, display, computer) ); } } + +/** + * Measure every cell of an already laid out display. + * + * @remarks + * The counterpart to calling {@link fillTableDisplay} without a computer: the + * collapsing border model has to resolve the table's own borders — from cell + * coordinates alone — before the width those cells are measured against + * exists. Splitting the two keeps the grid walked once either way. + */ +export function measureDisplay( + display: Display, + computer: TCellConstraintsComputer +) { + for (const cell of display.cells) { + cell.constraints = computer.computeCellConstraints(cell.tnode); + } +} diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts new file mode 100644 index 0000000..7d82a36 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -0,0 +1,369 @@ +import { ViewStyle } from 'react-native'; +import { TNode } from '@native-html/render'; +import { Display, DisplayCell, TableCell } from '../shared-types'; + +export type BorderCollapse = 'collapse' | 'separate'; + +export type CellVerticalAlign = 'baseline' | 'bottom' | 'middle' | 'top'; + +function getInlineStyleValue( + tnode: TNode, + propertyName: string +): string | null { + const inlineStyle = tnode.attributes.style; + if (!inlineStyle) { + return null; + } + let value: string | null = null; + for (const declaration of inlineStyle.split(';')) { + const colonIndex = declaration.indexOf(':'); + if (colonIndex === -1) { + continue; + } + const name = declaration.slice(0, colonIndex).trim().toLowerCase(); + if (name === propertyName) { + value = declaration + .slice(colonIndex + 1) + .replace(/\s*!important\s*$/i, '') + .trim() + .toLowerCase(); + } + } + return value; +} + +function normalizeVerticalAlign(value: string): CellVerticalAlign | null { + switch (value.toLowerCase()) { + case 'top': + case 'middle': + case 'bottom': + case 'baseline': + return value.toLowerCase() as CellVerticalAlign; + case 'initial': + case 'unset': + return 'baseline'; + case 'inherit': + case 'revert': + case 'revert-layer': + return null; + default: + // Lengths, percentages and the inline-only vertical-align keywords are + // treated as baseline for table cells by CSS. + return 'baseline'; + } +} + +/** + * The alignment HTML's user-agent stylesheet gives a table cell. + * + * @remarks + * Row groups and direct table rows are aligned to the middle, and rows and + * cells inherit it. Being a user-agent declaration, it is outranked by any + * author style that resolves to the same native property. + * + * @public + */ +export const DEFAULT_CELL_VERTICAL_ALIGN: CellVerticalAlign = 'middle'; + +/** + * Resolve the vertical alignment a native table cell should emulate. + * + * The CSS processor intentionally drops `vertical-align` because React Native + * cannot consume it directly, so table renderers recover the value from inline + * CSS and the legacy `valign` attribute here. + * + * @returns The declared alignment, or `null` when the cell inherits nothing + * but {@link DEFAULT_CELL_VERTICAL_ALIGN}. Callers need the distinction: the + * default may not overwrite an author `justify-content`, whereas a declared + * alignment must. + */ +export function resolveCellVerticalAlign( + tnode: TNode +): CellVerticalAlign | null { + for ( + let current: TNode | null = tnode; + current && current.tagName !== 'table'; + current = current.parent + ) { + const inlineValue = getInlineStyleValue(current, 'vertical-align'); + if (inlineValue) { + const normalized = normalizeVerticalAlign(inlineValue); + if (normalized) { + return normalized; + } + } + const attributeValue = current.attributes.valign; + if (attributeValue) { + const normalized = normalizeVerticalAlign(attributeValue); + if (normalized) { + return normalized; + } + } + } + return null; +} + +/** + * Resolve whether a table uses the collapsing border model. + * + * Inline `border-collapse` is not part of React Native styles, so it must be + * read from the source DOM. The `rules` attribute also implies collapsed + * borders in the HTML rendering rules. + */ +export function resolveBorderCollapse( + tnode: TNode, + configuredValue?: BorderCollapse +): boolean { + if (configuredValue) { + return configuredValue === 'collapse'; + } + const ownValue = getInlineStyleValue(tnode, 'border-collapse'); + if (ownValue === 'collapse' || ownValue === 'separate') { + return ownValue === 'collapse'; + } + if (tnode.attributes.rules) { + return true; + } + // border-collapse is inherited. Only inline declarations are available to + // the plugin after unsupported web-only properties have been processed. + for (let parent = tnode.parent; parent; parent = parent.parent) { + const inheritedValue = getInlineStyleValue(parent, 'border-collapse'); + if (inheritedValue === 'collapse' || inheritedValue === 'separate') { + return inheritedValue === 'collapse'; + } + } + return false; +} + +type BorderSide = 'Bottom' | 'Left' | 'Right' | 'Top'; + +interface BorderCandidate { + color: ViewStyle['borderColor']; + fromCell: boolean; + style: NonNullable; + width: number; +} + +const borderStylePriority: Record = { + dotted: 0, + dashed: 1, + solid: 2 +}; + +function borderCandidate( + style: ViewStyle, + side: BorderSide, + fromCell: boolean +): BorderCandidate { + // The CSS processor always expands `border` per side, but + // `getStyleForCell` is hand-written and the shorthand is the natural way to + // reach for a border there, so fall back to it. An explicit per-side `0` + // still wins, as it does in React Native. + const width = (style[`border${side}Width`] ?? style.borderWidth) as + | number + | undefined; + const color = (style[`border${side}Color`] ?? + style.borderColor) as ViewStyle['borderColor']; + return { + color: color ?? 'black', + fromCell, + style: style.borderStyle ?? 'solid', + width: typeof width === 'number' ? width : 0 + }; +} + +function resolveBorderConflict( + winner: BorderCandidate, + candidate: BorderCandidate +): BorderCandidate { + if (candidate.width !== winner.width) { + return candidate.width > winner.width ? candidate : winner; + } + const candidatePriority = borderStylePriority[candidate.style]; + const winnerPriority = borderStylePriority[winner.style]; + if (candidatePriority !== winnerPriority) { + return candidatePriority > winnerPriority ? candidate : winner; + } + // With otherwise equal borders, CSS gives a cell precedence over the table. + return candidate.fromCell && !winner.fromCell ? candidate : winner; +} + +/** + * A cell as the collapsing border model sees it: where it sits in the matrix, + * and the node its source styles come from. + */ +type CollapsibleCell = Pick; + +/** + * The matrix a collapsed border is resolved over. + * + * @remarks + * `maxX` and `maxY` come from the display rather than from the cells, so that + * this agrees with {@link getCollapsedCellBorderStyle} on which cells are at + * an edge. The two disagree for a `rowspan` that overruns the last row: the + * table does not grow rows to fit it, so the cell is clipped and the last row + * the display laid out stays the bottom edge. + */ +type CollapsibleMatrix = { + cells: readonly C[]; +} & Pick; + +function cellsAtOuterEdge( + { cells, maxX, maxY }: CollapsibleMatrix, + side: BorderSide +): readonly C[] { + return cells.filter((cell) => { + switch (side) { + case 'Top': + return cell.y === 0; + case 'Right': + return cell.x + cell.lenX - 1 >= maxX; + case 'Bottom': + return cell.y + cell.lenY - 1 >= maxY; + case 'Left': + return cell.x === 0; + } + }); +} + +function sourceCellStyle(cell: CollapsibleCell): ViewStyle { + return cell.tnode.styles.nativeBlockRet; +} + +/** + * Resolve each outer collapsed border between the table and its edge cells. + * + * React Native cannot render different border segments along one side of a + * View, so the strongest cell candidate is used for that complete side. This + * still preserves the central CSS conflict rules: wider borders win, then + * stronger styles, then cells over the table. + * + * @param matrix - See {@link CollapsibleMatrix}. + * @param tableStyle - What the table itself brings to the conflict. Passing + * the result of an earlier resolution narrows it further, which is how + * {@link HeuristicTablePluginConfig.getStyleForCell} joins in once the cell + * widths it is handed exist. + * @param getCellStyle - Everything an edge cell paints with. Defaults to its + * source CSS alone. + */ +export function getCollapsedTableBorderStyle( + matrix: CollapsibleMatrix, + tableStyle: ViewStyle, + getCellStyle: (cell: C) => ViewStyle = sourceCellStyle +): ViewStyle { + const resolvedStyle: ViewStyle = {}; + let strongestStyle: BorderCandidate['style'] = + tableStyle.borderStyle ?? 'solid'; + for (const side of ['Top', 'Right', 'Bottom', 'Left'] as const) { + const winner = cellsAtOuterEdge(matrix, side).reduce( + (currentWinner, cell) => + resolveBorderConflict( + currentWinner, + borderCandidate(getCellStyle(cell), side, true) + ), + borderCandidate(tableStyle, side, false) + ); + Object.assign(resolvedStyle, { + [`border${side}Width`]: winner.width, + [`border${side}Color`]: winner.color + }); + if ( + borderStylePriority[winner.style] > borderStylePriority[strongestStyle] + ) { + strongestStyle = winner.style; + } + } + resolvedStyle.borderStyle = strongestStyle; + return resolvedStyle; +} + +/** + * Which boundaries of the table a cell sits against. + * + * @remarks + * `tableBorderStyle` is the wrapper edge {@link getCollapsedTableBorderStyle} + * resolved, and is consulted rather than assumed: a side the wrapper leaves + * bare has to stay with the cell. + */ +export interface CollapsedCellEdges { + maxX: number; + maxY: number; + tableBorderStyle: ViewStyle | null; +} + +/** + * Draw every shared cell boundary exactly once. + * + * @param cell - The cell's position in the table matrix. + * @param cellStyle - Everything the cell paints with, source CSS and + * {@link HeuristicTablePluginConfig.getStyleForCell} alike. + * @param edges - See {@link CollapsedCellEdges}. + * + * @remarks + * Each cell owns its trailing and bottom boundary, and the table wrapper owns + * the four outer ones it resolved against the edge cells. This mirrors the + * visible result of the collapsing model for the border styles React Native + * can render, without changing the flex geometry used for row and col spans. + * + * Two consequences of drawing a boundary once are worth spelling out. An + * interior boundary falls back to the opposite half of the same cell, so cells + * carrying `border-top` alone still rule off every row: under uniform cell + * styling — the case worth optimising for, since React Native cannot paint one + * side of a View in two segments anyway — both halves are the same + * declaration. And an outer boundary the wrapper resolved to nothing stays + * with the cell, so a table that declares no border of its own still shows the + * frame its edge cells ask for. + * + * `tableBorderStyle` must therefore be the edge resolved against everything + * `cellStyle` holds, `getStyleForCell` included — otherwise a border only the + * config declares loses to the weaker one the wrapper resolved from source CSS + * and is painted by neither. + */ +export function getCollapsedCellBorderStyle( + cell: Pick, + cellStyle: ViewStyle, + { maxX, maxY, tableBorderStyle }: CollapsedCellEdges +): ViewStyle { + const resolvedStyle: ViewStyle = {}; + // A span that overruns the matrix is clipped to it rather than growing the + // table, so it sits at the edge it overran. + const isOuterEdge: Record = { + Top: cell.y === 0, + Right: cell.x + cell.lenX - 1 >= maxX, + Bottom: cell.y + cell.lenY - 1 >= maxY, + Left: cell.x === 0 + }; + const isPaintedByTable = (side: BorderSide) => { + const width = tableBorderStyle?.[`border${side}Width`]; + return typeof width === 'number' && width > 0; + }; + const paint = (side: BorderSide, candidate: BorderCandidate | null) => { + if (!candidate || candidate.width === 0) { + Object.assign(resolvedStyle, { [`border${side}Width`]: 0 }); + return; + } + Object.assign(resolvedStyle, { + [`border${side}Width`]: candidate.width, + [`border${side}Color`]: candidate.color + }); + }; + const ownBorder = (side: BorderSide) => borderCandidate(cellStyle, side, true); + const keepOuterBorder = (side: BorderSide) => + isPaintedByTable(side) ? null : ownBorder(side); + // A leading boundary is always drawn by the neighbour that precedes it, + // except on the outside where there is no neighbour to draw it. + paint('Top', isOuterEdge.Top ? keepOuterBorder('Top') : null); + paint('Left', isOuterEdge.Left ? keepOuterBorder('Left') : null); + for (const [side, opposite] of [ + ['Right', 'Left'], + ['Bottom', 'Top'] + ] as const) { + paint( + side, + isOuterEdge[side] + ? keepOuterBorder(side) + : resolveBorderConflict(ownBorder(side), ownBorder(opposite)) + ); + } + return resolvedStyle; +} diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 0694b89..866b824 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -172,6 +172,10 @@ export interface Settings { * @defaultValue \{ normal: 1, bold: 1.3, '100': 0.8 … '900': 1.5 \} */ fontWeightCoeffs?: FontWeightCoefficients; + /** + * Override the table's `border-collapse` mode. + */ + borderCollapse?: 'collapse' | 'separate'; /** * Available width at the root of the render tree, prior to scrolling. * @@ -232,6 +236,13 @@ export interface HeuristicTablePluginConfig { * @defaultValue \{ normal: 1, bold: 1.3, '100': 0.8 … '900': 1.5 \} */ fontWeightCoeffs?: FontWeightCoefficients; + /** + * Override the table's border model. When omitted, an inline + * `border-collapse` declaration from the table is used. + * + * @defaultValue `separate` + */ + borderCollapse?: 'collapse' | 'separate'; /** * Customize cells appearance with this function. * diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index 5b4c57f..79b42a8 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -1,6 +1,36 @@ +import { ViewStyle } from 'react-native'; import { TBlock, CustomRendererProps } from '@native-html/render'; import { TableCellPropsFromParent } from './shared-types'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; +import { + CellVerticalAlign, + getCollapsedCellBorderStyle, + resolveCellVerticalAlign +} from './helpers/tableStyles'; + +/** + * How a table cell emulates `vertical-align` in a column flex container. + * + * @remarks + * `baseline` has no native equivalent for a block box, and a cell's first line + * box sits at its top, so it collapses onto the same alignment as `top`. + */ +const justifyContentForVerticalAlign: Record< + CellVerticalAlign, + NonNullable +> = { + baseline: 'flex-start', + bottom: 'flex-end', + middle: 'center', + top: 'flex-start' +}; + +interface InternalTableCellPropsFromParent extends TableCellPropsFromParent { + borderCollapse: boolean; + maxX: number; + maxY: number; + tableBorderStyle: ViewStyle | null; +} /** * Customize `td` and `th` renderers while reusing default cell renderer logic. @@ -13,24 +43,42 @@ export default function useHtmlTableCellProps({ propsFromParent, ...props }: CustomRendererProps): CustomRendererProps { - const { config, cell } = propsFromParent as TableCellPropsFromParent; + const { borderCollapse, config, cell, maxX, maxY, tableBorderStyle } = + propsFromParent as InternalTableCellPropsFromParent; const styleFromConfig = config?.getStyleForCell?.call(null, cell); - // Vertical and horizontal centering are independent, so a cell that both - // spans rows and spans columns must keep the two: assigning here rather than - // merging would drop the vertical centering of every `rowspan`+`colspan` - // cell. - const spanStyles = { - ...(cell.lenY > 1 ? { justifyContent: 'center' as const } : null), + const verticalAlign = resolveCellVerticalAlign(props.tnode); + // Vertical table-cell alignment and horizontal colspan centering are + // independent, so keep both declarations in the same style contribution. + // + // A declared `vertical-align` is the author declaration that targets cell + // alignment, so it wins. Absent one, the middle default is only the + // user-agent stylesheet's, and may not overwrite a `justify-content` the + // cell already resolved from `tagsStyles` or its own CSS. + const alignmentStyles = { + justifyContent: verticalAlign + ? justifyContentForVerticalAlign[verticalAlign] + : (props.style?.justifyContent ?? 'center'), ...(cell.lenX > 1 ? { alignItems: 'center' as const } : null) }; + // The collapsing model has to weigh every border the cell actually paints, + // config included: resolving it against the source CSS alone would strip a + // border that came from `getStyleForCell` and leave nothing to draw it. + const collapsedBorderStyle = borderCollapse + ? getCollapsedCellBorderStyle( + cell, + { ...props.tnode.styles.nativeBlockRet, ...styleFromConfig }, + { maxX, maxY, tableBorderStyle } + ) + : null; const style = { // An explicit height on a cell is a minimum height in HTML, so that the // cell still grows to fit its content. ...relaxHeightConstraint(props.style), flexGrow: 1, flexShrink: 0, - ...spanStyles, + ...alignmentStyles, ...styleFromConfig, + ...collapsedBorderStyle, width: cell.width, marginLeft: 0, marginRight: 0, diff --git a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts index 15d9215..4e636ef 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts @@ -44,6 +44,7 @@ export default function useHtmlTableProps( const forceStretch = table?.forceStretch; const baseFontCoeff = table?.baseFontCoeff; const fontWeightCoeffs = table?.fontWeightCoeffs; + const borderCollapse = table?.borderCollapse; const sharedContentWidth = useContentWidth(); const contentWidth = typeof options.overrideContentWidth === 'number' @@ -54,9 +55,16 @@ export default function useHtmlTableProps( contentWidth, forceStretch, baseFontCoeff, - fontWeightCoeffs + fontWeightCoeffs, + borderCollapse }), - [contentWidth, forceStretch, baseFontCoeff, fontWeightCoeffs] + [ + contentWidth, + forceStretch, + baseFontCoeff, + fontWeightCoeffs, + borderCollapse + ] ); const layout = useTableLayout({ tnode, settings }); return { From 6c3b7ba503082bc2598d5b3293a0f14fdb9fe375 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Thu, 10 Sep 2026 15:14:13 +0200 Subject: [PATCH 06/21] feat(heuristic-table-plugin): add default padding --- packages/heuristic-table-plugin/README.md | 21 ++- .../heuristic-table-plugin/src/HTMLTable.tsx | 6 +- .../src/helpers/TCellConstraintsComputer.ts | 14 +- .../TCellConstraintsComputer.test.ts | 92 +++++++++++-- .../__tests__/resolveAvailableWidth.test.ts | 58 +++++++- .../src/helpers/__tests__/tableStyles.test.ts | 81 +++++++++++ .../__tests__/useHtmlTableCellProps.test.ts | 116 ++++++++++++++++ .../src/helpers/__tests__/utils.ts | 24 ++-- .../src/helpers/measure.ts | 13 -- .../src/helpers/resolveAvailableWidth.ts | 7 +- .../src/helpers/tableStyles.ts | 126 ++++++++++++++++++ .../src/useHtmlTableCellProps.ts | 11 ++ 12 files changed, 524 insertions(+), 45 deletions(-) create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts diff --git a/packages/heuristic-table-plugin/README.md b/packages/heuristic-table-plugin/README.md index 9902091..5452f2c 100644 --- a/packages/heuristic-table-plugin/README.md +++ b/packages/heuristic-table-plugin/README.md @@ -102,6 +102,22 @@ to the `renderersProps.table` prop of `RenderHTML` component. See the documentation for this object here: [`HeuristicTablePluginConfig`](docs/heuristic-table-plugin.heuristictablepluginconfig.md) +### Cell padding + +As in HTML, where the user-agent stylesheet declares `td, th { padding: 1px }`, +cells are padded by one pixel on every side they declare no padding for. It is +a user-agent declaration, so any author padding outranks it, side by side: a +cell with `padding-left: 8px` keeps the default pixel on the three sides it +left alone, and `padding: 0` removes it altogether. A padding from +`getStyleForCell`, shorthand included, replaces it too. + +Be aware that column widths are measured before `getStyleForCell` is called — +the widths it is handed are its input — so padding declared there is painted +but not measured. A `getStyleForCell` returning `{ padding: 8 }` spends 16px +per cell that the columns were never sized for, and content wraps earlier than +it otherwise would. Declare padding in your CSS instead whenever the column +widths should account for it. + ## Custom Renderers ### Customizing Root renderer @@ -197,7 +213,10 @@ In the first step, each cell of the table is parsed to extract three metrics: - `minWidth`, an estimate of the cell's min-content width: its longest unbreakable text run or the greatest width imposed by one of its blocks, - plus horizontal spacing; + plus horizontal spacing — the cell's borders and padding, the + [default cell padding](#cell-padding) included. Margins take no part: the + cell renderer zeroes them, so column width reserved for one would only + leave a gap nothing paints; - `maxWidth`, the width beyond which the cell would gain nothing, bounded by the cell's own `max-width` but never below `minWidth`; - `contentDensity`, an estimate of the width taken by all the cell's text on diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index da94e50..c06f8d6 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -100,12 +100,10 @@ const HTMLTable = memo(function HTMLTable({ // scroller inside. A table narrower than that keeps its own size, // insets included. width: Math.min(tableWidth + insets, layout.usedWidth) - }} - > + }}> + availableWidth={layout.assignableWidth}> {React.createElement(TreeRenderer, { node: layout.renderTree, config, diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index 32c0dcc..c2646a5 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -5,7 +5,8 @@ import max from 'ramda/src/max'; import reduce from 'ramda/src/reduce'; import { TNode } from '@native-html/render'; import { TCellConstraints, TConstraintsBase } from '../shared-types'; -import { getHorizontalMargins, getHorizontalSpacing } from './measure'; +import { getHorizontalInsets, getHorizontalMargins } from './measure'; +import { getPaintedBlockStyle } from './tableStyles'; import { resolveCssSize, resolveImposedWidth } from './resolveWidth'; interface TextChunkStats { @@ -26,7 +27,9 @@ interface TextChunkStats { */ interface TCellStats { /** - * Horizontal spacing for this cell + * The cell's own horizontal insets: its padding and border. Margins are + * excluded because the cell renderer zeroes them, so reserving column width + * for one would leave a gap nothing ever paints. */ horizontalSpace: number; /** @@ -50,7 +53,12 @@ function getInitCellStatsForTnode(tnode: TNode): TCellStats { return { blockWidth: 0, cellBoxWidth: null, - horizontalSpace: getHorizontalSpacing(tnode.styles.nativeBlockRet), + // The padding a cell gets from the user-agent stylesheet is space its + // content cannot use, exactly like a declared one, so the intrinsic widths + // reserve it here as well as the cell renderer paints it. Config styles + // take no part in this pass, so a padding only `getStyleForCell` declares + // stays measured as the default it replaces. + horizontalSpace: getHorizontalInsets(getPaintedBlockStyle(tnode)), textStats: [] }; } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index d983c5b..6f8cd89 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -4,6 +4,7 @@ import TCellConstraintsComputer, { FontWeightCoefficients } from '../TCellConstraintsComputer'; import { TCellConstraints } from '../../shared-types'; +import { DEFAULT_CELL_PADDING } from '../tableStyles'; import { createTableTNode } from './utils'; function findFirstCell(tnode: TNode): TNode | null { @@ -26,6 +27,13 @@ function findFirstCell(tnode: TNode): TNode | null { */ const BASE_FONT_COEFF = 0.65; +/** + * The horizontal room a cell which declares no padding of its own still owes + * to the user-agent stylesheet, and which every intrinsic width below + * therefore carries on top of its text. + */ +const DEFAULT_HORIZONTAL_PADDING = 2 * DEFAULT_CELL_PADDING; + function constraintsFor( cellMarkup: string, contentWidth = 400, @@ -49,7 +57,8 @@ describe('TCellConstraintsComputer', () => { ); expect(minWidth).toBeCloseTo( - 6 * 14 * BASE_FONT_COEFF * (DEFAULT_FONT_WEIGHT_COEFFS.bold as number) + DEFAULT_HORIZONTAL_PADDING + + 6 * 14 * BASE_FONT_COEFF * (DEFAULT_FONT_WEIGHT_COEFFS.bold as number) ); }); @@ -61,7 +70,9 @@ describe('TCellConstraintsComputer', () => { ); // A cell of bold text now measures exactly as one of regular text. - expect(minWidth).toBeCloseTo(6 * 14 * BASE_FONT_COEFF); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 6 * 14 * BASE_FONT_COEFF + ); }); it('should keep the defaults a partial config leaves untouched', () => { @@ -74,7 +85,11 @@ describe('TCellConstraintsComputer', () => { ); expect(minWidth).toBeCloseTo( - 6 * 14 * BASE_FONT_COEFF * (DEFAULT_FONT_WEIGHT_COEFFS['300'] as number) + DEFAULT_HORIZONTAL_PADDING + + 6 * + 14 * + BASE_FONT_COEFF * + (DEFAULT_FONT_WEIGHT_COEFFS['300'] as number) ); }); }); @@ -85,13 +100,17 @@ describe('TCellConstraintsComputer', () => { // The longest unbreakable segment is "Medium-" (7 characters), not the // full 11-character string. - expect(minWidth).toBeCloseTo(7 * 14 * BASE_FONT_COEFF); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 7 * 14 * BASE_FONT_COEFF + ); }); it('should retain a non-breaking hyphen in one segment', () => { const { minWidth } = constraintsFor('Medium‑High'); - expect(minWidth).toBeCloseTo(11 * 14 * BASE_FONT_COEFF); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 11 * 14 * BASE_FONT_COEFF + ); }); it('should not break a hyphen between two digits', () => { @@ -99,27 +118,82 @@ describe('TCellConstraintsComputer', () => { // worse than a wide one. const { minWidth } = constraintsFor('2026-09-03'); - expect(minWidth).toBeCloseTo(10 * 14 * BASE_FONT_COEFF); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 10 * 14 * BASE_FONT_COEFF + ); }); it('should still break a hyphen with a digit on only one side', () => { // "ISO-" is the longest segment; the digits stand alone after the break. const { minWidth } = constraintsFor('ISO-2026'); - expect(minWidth).toBeCloseTo(4 * 14 * BASE_FONT_COEFF); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 4 * 14 * BASE_FONT_COEFF + ); }); it('should not break at a non-breaking space', () => { // A whole grouped number is one unbreakable run of nine characters. const { minWidth } = constraintsFor('10 000 km'); - expect(minWidth).toBeCloseTo(9 * 14 * BASE_FONT_COEFF); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 9 * 14 * BASE_FONT_COEFF + ); }); it('should break at a regular space', () => { const { minWidth } = constraintsFor('10 000 km'); - expect(minWidth).toBeCloseTo(3 * 14 * BASE_FONT_COEFF); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 3 * 14 * BASE_FONT_COEFF + ); + }); + }); + + describe('default cell padding', () => { + it('should reserve the padding a bare cell gets from the user agent', () => { + const bare = constraintsFor('Method'); + const unpadded = constraintsFor('Method'); + + expect(bare.minWidth - unpadded.minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + ); + expect(bare.maxWidth - unpadded.maxWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + ); + }); + + it('should let a declared padding replace the default, not join it', () => { + // 8px on each side, so 16px of spacing — never 18px. + const declared = constraintsFor('Method'); + const unpadded = constraintsFor('Method'); + + expect(declared.minWidth - unpadded.minWidth).toBeCloseTo(16); + }); + + it('should ignore a margin the cell renderer zeroes', () => { + // `useHtmlTableCellProps` unconditionally zeroes all four margins, so + // width reserved for one here only widens the column by a gap nothing + // ever paints. + const withMargin = constraintsFor( + 'Method' + ); + const bare = constraintsFor('Method'); + expect(withMargin.minWidth).toBe(bare.minWidth); + expect(withMargin.maxWidth).toBe(bare.maxWidth); + }); + + it('should reserve the default beside a padding declared on one side', () => { + const oneSided = constraintsFor( + 'Method' + ); + const unpadded = constraintsFor('Method'); + + // The right side keeps the user-agent pixel it was never given a + // declaration for. + expect(oneSided.minWidth - unpadded.minWidth).toBeCloseTo( + 8 + DEFAULT_CELL_PADDING + ); }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts index 2b9e14d..ea4db3b 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts @@ -1,8 +1,8 @@ import resolveAvailableWidth from '../resolveAvailableWidth'; import { createTableTNode } from './utils'; -function availableWidthFor(html: string, contentWidth: number) { - return resolveAvailableWidth(createTableTNode(html), contentWidth); +function availableWidthFor(html: string, contentWidth: number, nth = 0) { + return resolveAvailableWidth(createTableTNode(html, nth), contentWidth); } describe('resolveAvailableWidth', () => { @@ -111,6 +111,60 @@ describe('resolveAvailableWidth', () => { ).toBe(400); }); + describe('table cell ancestors', () => { + // The user-agent `td, th { padding: 1px }` never reaches `nativeBlockRet`, + // so a cell which declares nothing looks bare here while the renderer + // still spends the padding. Measuring the declared insets handed a nested + // table 2px more than its cell had left, once per level of nesting. + const NESTED_TABLE = '
A
'; + + it('should subtract the default padding of a bare cell', () => { + expect( + availableWidthFor( + `
${NESTED_TABLE}
`, + 400, + 1 + ) + ).toBe(398); + }); + + it('should subtract a declared cell padding instead of the default', () => { + expect( + availableWidthFor( + `
${NESTED_TABLE}
`, + 400, + 1 + ) + ).toBe(380); + }); + + it('should subtract nothing from a cell which zeroes its padding', () => { + expect( + availableWidthFor( + `
${NESTED_TABLE}
`, + 400, + 1 + ) + ).toBe(400); + }); + + it('should keep the default on the sides a cell leaves undeclared', () => { + expect( + availableWidthFor( + `
${NESTED_TABLE}
`, + 400, + 1 + ) + ).toBe(400 - 10 - 1); + }); + + it('should not give the default padding to a non-cell ancestor', () => { + expect( + availableWidthFor(`
${NESTED_TABLE}
`, 400) + ).toBe(400); + }); + }); + it('should raise a narrow ancestor up to its min-width', () => { expect( availableWidthFor( diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts index 00f77ff..a0c2749 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts @@ -1,6 +1,7 @@ import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle, + getDefaultCellPaddingStyle, resolveBorderCollapse, resolveCellVerticalAlign } from '../tableStyles'; @@ -73,6 +74,86 @@ describe('table styles', () => { }); }); + describe('default padding', () => { + const ONE_PIXEL_EVERY_SIDE = { + paddingTop: 1, + paddingRight: 1, + paddingBottom: 1, + paddingLeft: 1 + }; + + it('gives a bare cell one pixel on every side', () => { + expect( + getDefaultCellPaddingStyle( + findCell('
A
').styles.nativeBlockRet + ) + ).toEqual(ONE_PIXEL_EVERY_SIDE); + }); + + it('leaves the sides an author declared alone', () => { + // Source CSS reaches the plugin expanded per side, so a `padding-left` + // replaces the default on that side alone — as it does in a browser. + expect( + getDefaultCellPaddingStyle( + findCell( + '
A
' + ).styles.nativeBlockRet + ) + ).toEqual({ paddingTop: 1, paddingRight: 1, paddingBottom: 1 }); + }); + + it('declares nothing for a cell padded on all sides', () => { + expect( + getDefaultCellPaddingStyle( + findCell('
A
') + .styles.nativeBlockRet + ) + ).toEqual({}); + }); + + it('keeps a zero padding at zero', () => { + expect( + getDefaultCellPaddingStyle( + findCell('
A
') + .styles.nativeBlockRet + ) + ).toEqual({}); + }); + + it('reads a shorthand from the config as a declaration of every side', () => { + // Yoga resolves a side against its own edge before the `padding` one, so + // a longhand default would outrank this shorthand however it is merged. + expect(getDefaultCellPaddingStyle(null, { padding: 8 })).toEqual({}); + }); + + it('reads an axis shorthand from the config on that axis alone', () => { + expect(getDefaultCellPaddingStyle(null, { paddingVertical: 8 })).toEqual({ + paddingRight: 1, + paddingLeft: 1 + }); + }); + + it('reserves both horizontal sides for a writing-direction keyword', () => { + // Which side `paddingStart` lands on is not known here, so neither may + // be given a default that would fight it. + expect(getDefaultCellPaddingStyle(null, { paddingStart: 8 })).toEqual({ + paddingTop: 1, + paddingBottom: 1 + }); + }); + + it('lets the config decide a side the source CSS left bare', () => { + expect( + getDefaultCellPaddingStyle( + findCell( + '
A
' + ).styles.nativeBlockRet, + { paddingHorizontal: 4 } + ) + ).toEqual({ paddingBottom: 1 }); + }); + }); + describe('border collapse', () => { it('keeps separate borders by default', () => { const table = createTableTNode('
A
'); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts new file mode 100644 index 0000000..f9f5b29 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts @@ -0,0 +1,116 @@ +import { ViewStyle } from 'react-native'; +import { CustomRendererProps, TBlock, TNode } from '@native-html/render'; +import useHtmlTableCellProps from '../../useHtmlTableCellProps'; +import { HeuristicTablePluginConfig, TableCell } from '../../shared-types'; +import { createTableTNode } from './utils'; + +function findFirstCell(tnode: TNode): TNode | null { + if (tnode.tagName === 'td' || tnode.tagName === 'th') { + return tnode; + } + for (const child of tnode.children) { + const cell = findFirstCell(child); + if (cell) { + return cell; + } + } + return null; +} + +/** + * The style the cell renderer hands to the default renderer for the first cell + * of `cellMarkup`. + * + * @remarks + * `useHtmlTableCellProps` calls no React hook, so it is exercised as the plain + * function it is. Everything the props carry beyond the fields it reads is + * irrelevant to the style it resolves. + */ +function cellStyleFor( + cellMarkup: string, + config: HeuristicTablePluginConfig = {} +): ViewStyle { + const tnode = findFirstCell( + createTableTNode(`${cellMarkup}
`) + ); + expect(tnode).not.toBeNull(); + const cell: TableCell = { + type: 'cell', + tnode: tnode as TNode, + x: 0, + y: 0, + lenX: 1, + lenY: 1, + width: 100, + constraints: { minWidth: 0, maxWidth: 100, contentDensity: 0 } + }; + const props = { + tnode, + style: tnode?.styles.nativeBlockRet, + propsFromParent: { + cell, + config, + borderCollapse: false, + maxX: 0, + maxY: 0, + tableBorderStyle: null + } + } as unknown as CustomRendererProps; + return useHtmlTableCellProps(props).style as ViewStyle; +} + +describe('useHtmlTableCellProps', () => { + describe('default padding', () => { + it('pads a bare cell by one pixel, as HTML does', () => { + expect(cellStyleFor('A')).toMatchObject({ + paddingTop: 1, + paddingRight: 1, + paddingBottom: 1, + paddingLeft: 1 + }); + }); + + it('yields the sides the cell CSS declares', () => { + expect(cellStyleFor('A')).toMatchObject({ + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8 + }); + }); + + it('keeps the default on the sides that CSS leaves out', () => { + expect( + cellStyleFor('A') + ).toMatchObject({ + paddingTop: 1, + paddingRight: 1, + paddingBottom: 1, + paddingLeft: 8 + }); + }); + + it('honours a padding the config declares', () => { + const style = cellStyleFor('A', { + getStyleForCell: () => ({ padding: 8 }) + }); + + // No longhand may be emitted beside the config shorthand: Yoga resolves + // a side against its own edge first, so a default of 1 would win. + expect(style.padding).toBe(8); + expect(style).not.toHaveProperty('paddingTop'); + expect(style).not.toHaveProperty('paddingRight'); + expect(style).not.toHaveProperty('paddingBottom'); + expect(style).not.toHaveProperty('paddingLeft'); + }); + + it('leaves a cell asking for no padding unpadded', () => { + expect(cellStyleFor('A')).toMatchObject({ + paddingTop: 0, + paddingRight: 0, + paddingBottom: 0, + paddingLeft: 0 + }); + }); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts index 8a6c065..8c3edb6 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts @@ -11,26 +11,26 @@ const engine = new TRenderEngine({ } }); -function findTable(tnode: TNode): TNode | null { +function collectTables(tnode: TNode, found: TNode[] = []): TNode[] { if (tnode.tagName === 'table') { - return tnode; + found.push(tnode); } for (const child of tnode.children) { - const table = findTable(child); - if (table) { - return table; - } + collectTables(child, found); } - return null; + return found; } /** - * Build a transient render tree from `html` and return its first `table`, - * however deeply it is nested. The tnode keeps its ancestors, so helpers which - * walk up the tree see the real containing blocks. + * Build a transient render tree from `html` and return one of its `table` + * nodes, however deeply nested. The tnode keeps its ancestors, so helpers + * which walk up the tree see the real containing blocks. + * + * @param nth - Which table to return, in document order. Defaults to the + * outermost one; pass `1` for the table nested inside it. */ -export function createTableTNode(html: string) { - const table = findTable(engine.buildTTree(html) as unknown as TNode); +export function createTableTNode(html: string, nth = 0) { + const table = collectTables(engine.buildTTree(html) as unknown as TNode)[nth]; expect(table?.tagName).toBe('table'); return table as TNode; } diff --git a/packages/heuristic-table-plugin/src/helpers/measure.ts b/packages/heuristic-table-plugin/src/helpers/measure.ts index b7ab5da..5ef4946 100644 --- a/packages/heuristic-table-plugin/src/helpers/measure.ts +++ b/packages/heuristic-table-plugin/src/helpers/measure.ts @@ -20,15 +20,6 @@ const hinsetFields: readonly SpacingFields[] = [ 'paddingRight' ]; -const hspacingFields: readonly SpacingFields[] = [ - 'borderLeftWidth', - 'borderRightWidth', - 'paddingLeft', - 'paddingRight', - 'marginLeft', - 'marginRight' -]; - function sumFields( style: NativeBlockRetStyle, fields: readonly SpacingFields[] @@ -56,7 +47,3 @@ export function getHorizontalMargins(style: NativeBlockRetStyle): number { export function getHorizontalInsets(style: NativeBlockRetStyle): number { return sumFields(style, hinsetFields); } - -export function getHorizontalSpacing(style: NativeBlockRetStyle): number { - return sumFields(style, hspacingFields); -} diff --git a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts index ce91c84..707518f 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts @@ -1,12 +1,17 @@ import { TNode } from '@native-html/render'; import { getHorizontalInsets, getHorizontalMargins } from './measure'; import { clampWidth, resolveWidthConstraints } from './resolveWidth'; +import { getPaintedBlockStyle } from './tableStyles'; /** * The width `tnode` offers to a block-level child, i.e. its content box. */ function reduceToContentBox(tnode: TNode, containingWidth: number): number { - const style = tnode.styles.nativeBlockRet; + // The insets have to be the ones the ancestor is painted with rather than + // the ones it declares: a bare cell would otherwise hand its children the + // user-agent padding it is about to spend, and a table nested in it would + // overflow by that much once per level of nesting. + const style = getPaintedBlockStyle(tnode); const { width, minWidth, maxWidth } = resolveWidthConstraints( tnode, containingWidth diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index 7d82a36..eb47515 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -65,6 +65,132 @@ function normalizeVerticalAlign(value: string): CellVerticalAlign | null { */ export const DEFAULT_CELL_VERTICAL_ALIGN: CellVerticalAlign = 'middle'; +/** + * The padding HTML's user-agent stylesheet gives a table cell. + * + * @remarks + * `td, th { padding: 1px }`, per the + * {@link https://html.spec.whatwg.org/multipage/rendering.html#tables-2 | HTML rendering rules}. + * Being a user-agent declaration, it is outranked by any author padding, side + * by side: a cell which declares `padding-left` alone still gets the default + * on the three sides it left untouched. + * + * @public + */ +export const DEFAULT_CELL_PADDING = 1; + +type PaddingSide = 'Bottom' | 'Left' | 'Right' | 'Top'; + +/** + * Every style property which declares padding on a given side. + * + * @remarks + * Source CSS always reaches the plugin expanded per side, but + * {@link HeuristicTablePluginConfig.getStyleForCell} is hand-written React + * Native style, where any shorthand is fair game. A shorthand cannot simply be + * overwritten either: Yoga resolves a side against its own edge and only falls + * back to the `padding` edge, so a longhand default would beat an author + * `padding` whatever the merge order. Each shorthand is therefore read as a + * declaration of every side it covers. + * + * The writing-direction keywords count on both horizontal sides. Which of the + * two they land on is not known here, and reserving both is the harmless + * choice: it withholds a default rather than fighting the author declaration. + */ +const paddingSideKeys: Record = { + Top: [ + 'paddingTop', + 'paddingBlockStart', + 'paddingBlock', + 'paddingVertical', + 'padding' + ], + Right: [ + 'paddingRight', + 'paddingEnd', + 'paddingStart', + 'paddingInlineEnd', + 'paddingInlineStart', + 'paddingInline', + 'paddingHorizontal', + 'padding' + ], + Bottom: [ + 'paddingBottom', + 'paddingBlockEnd', + 'paddingBlock', + 'paddingVertical', + 'padding' + ], + Left: [ + 'paddingLeft', + 'paddingStart', + 'paddingEnd', + 'paddingInlineStart', + 'paddingInlineEnd', + 'paddingInline', + 'paddingHorizontal', + 'padding' + ] +}; + +/** + * Whether a node is a table cell, and so subject to the cell rules of the + * user-agent stylesheet. + */ +export function isTableCell(tnode: TNode): boolean { + return tnode.tagName === 'td' || tnode.tagName === 'th'; +} + +/** + * Everything a node is painted with, the user-agent cell rules included. + * + * @remarks + * `nativeBlockRet` holds source CSS alone, so a cell which declares no padding + * appears to have none while the renderer gives it + * {@link DEFAULT_CELL_PADDING}. Any pass which measures a box against what + * ends up on screen has to reconcile the two here first. + */ +export function getPaintedBlockStyle( + tnode: TNode +): TNode['styles']['nativeBlockRet'] { + const style = tnode.styles.nativeBlockRet; + if (!isTableCell(tnode)) { + return style; + } + return { ...getDefaultCellPaddingStyle(style), ...style }; +} + +/** + * The padding a table cell owes to {@link DEFAULT_CELL_PADDING} alone. + * + * @param declaredStyles - Everything the cell declares padding in, source CSS + * and {@link HeuristicTablePluginConfig.getStyleForCell} alike. A side any of + * them covers is left out of the result. + * + * @remarks + * The result is expanded per side rather than left as a `padding` shorthand, + * so that the sides an author did declare stay untouched. + */ +export function getDefaultCellPaddingStyle( + ...declaredStyles: (ViewStyle | null | undefined)[] +): ViewStyle { + const resolvedStyle: ViewStyle = {}; + for (const side of ['Top', 'Right', 'Bottom', 'Left'] as const) { + const isDeclared = declaredStyles.some((style) => + style + ? paddingSideKeys[side].some((property) => style[property] != null) + : false + ); + if (!isDeclared) { + Object.assign(resolvedStyle, { + [`padding${side}`]: DEFAULT_CELL_PADDING + }); + } + } + return resolvedStyle; +} + /** * Resolve the vertical alignment a native table cell should emulate. * diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index 79b42a8..fc3b383 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -5,6 +5,7 @@ import relaxHeightConstraint from './helpers/relaxHeightConstraint'; import { CellVerticalAlign, getCollapsedCellBorderStyle, + getDefaultCellPaddingStyle, resolveCellVerticalAlign } from './helpers/tableStyles'; @@ -70,7 +71,17 @@ export default function useHtmlTableCellProps({ { maxX, maxY, tableBorderStyle } ) : null; + // The user-agent padding is resolved against the config styles too, since a + // shorthand `padding` there cannot outrank a longhand default whatever the + // merge order: Yoga resolves each side against its own edge first. + const defaultPaddingStyle = getDefaultCellPaddingStyle( + props.tnode.styles.nativeBlockRet, + styleFromConfig + ); const style = { + // The user-agent stylesheet is the weakest declaration of the three, and + // only covers the sides no author declaration reached. + ...defaultPaddingStyle, // An explicit height on a cell is a minimum height in HTML, so that the // cell still grows to fit its content. ...relaxHeightConstraint(props.style), From c9c70e98526b5791a4b70001c149acbc210c08ac Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 14:08:31 +0200 Subject: [PATCH 07/21] feat(heuristic-table-plugin): resolve cell styles once and lay out against them --- packages/heuristic-table-plugin/README.md | 31 +- ...uristictablepluginconfig.bordercollapse.md | 13 + ...ristictablepluginconfig.getstyleforcell.md | 4 +- ...table-plugin.heuristictablepluginconfig.md | 21 ++ .../etc/heuristic-table-plugin.api.md | 1 + .../heuristic-table-plugin/src/HTMLTable.tsx | 37 +-- .../heuristic-table-plugin/src/TableLayout.ts | 125 ++++---- .../src/TreeRenderer.tsx | 8 +- .../src/helpers/TCellConstraintsComputer.ts | 189 ++++++------ .../src/helpers/__tests__/HTMLTable.test.tsx | 92 ++++++ .../TCellConstraintsComputer.test.ts | 140 +++++++-- .../src/helpers/__tests__/TableLayout.test.ts | 61 ++-- .../__tests__/computeColumnWidths.test.ts | 10 +- .../helpers/__tests__/layoutStyles.test.ts | 291 ++++++++++++++++++ .../__tests__/reduceColumnConstraints.test.ts | 16 +- .../__tests__/relaxHeightConstraint.test.ts | 5 +- .../helpers/__tests__/tableRendering.test.tsx | 44 +++ .../src/helpers/__tests__/tableStyles.test.ts | 58 +++- .../__tests__/useHtmlTableCellProps.test.ts | 26 ++ .../src/helpers/computeColumnWidths.ts | 32 +- .../src/helpers/measure.ts | 36 ++- .../src/helpers/resolveTableStyles.ts | 53 ++++ .../src/helpers/resolveWidth.ts | 6 +- .../src/helpers/tableStyles.ts | 73 +++-- .../src/shared-types.ts | 10 +- .../src/useHtmlTableCellProps.ts | 33 +- .../src/useHtmlTableProps.ts | 7 +- 27 files changed, 1084 insertions(+), 338 deletions(-) create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.bordercollapse.md create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx create mode 100644 packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts diff --git a/packages/heuristic-table-plugin/README.md b/packages/heuristic-table-plugin/README.md index 5452f2c..59fdcc1 100644 --- a/packages/heuristic-table-plugin/README.md +++ b/packages/heuristic-table-plugin/README.md @@ -111,12 +111,21 @@ cell with `padding-left: 8px` keeps the default pixel on the three sides it left alone, and `padding: 0` removes it altogether. A padding from `getStyleForCell`, shorthand included, replaces it too. -Be aware that column widths are measured before `getStyleForCell` is called — -the widths it is handed are its input — so padding declared there is painted -but not measured. A `getStyleForCell` returning `{ padding: 8 }` spends 16px -per cell that the columns were never sized for, and content wraps earlier than -it otherwise would. Declare padding in your CSS instead whenever the column -widths should account for it. +`getStyleForCell` padding and borders participate in layout. The plugin first +calculates provisional cell widths from source styles, calls the callback once +per cell, then calculates final widths using its returned styles. Those same +styles are reused when rendering, including when borders collapse. + +The callback's `cell.width` and constraints are **provisional**: they do not yet +include its returned styles. Width-dependent callbacks are not repeatedly +evaluated, so a callback that switches padding at a width threshold cannot +create a layout loop. Keep the callback referentially stable; changing it +recalculates the layout. + +In collapsed mode, shared borders are resolved against adjacent cells. If a +spanning cell meets several differently styled borders along one side, the +strongest border is used for that whole side. Native Views also have one border +style for all sides, so the strongest winning style is used for the View. ## Custom Renderers @@ -219,8 +228,9 @@ In the first step, each cell of the table is parsed to extract three metrics: leave a gap nothing paints; - `maxWidth`, the width beyond which the cell would gain nothing, bounded by the cell's own `max-width` but never below `minWidth`; -- `contentDensity`, an estimate of the width taken by all the cell's text on - one line. +- `contentDensity`, the sum of the estimated widths of all text; forced + line breaks do not reduce this density. `maxWidth` instead uses the widest + forced line, keeping text on separate lines from widening the column. ### 2. Column constraints reduction @@ -232,8 +242,9 @@ In the second step, cell constraints are reduced per column. Three metrics come `minWidth`. Widths and bounds declared by `` and `` are then folded into -these constraints. Percentage widths remain unresolved until the table's -assignable width is known. +these constraints. Percentage widths from cells, columns, and column groups remain preferences +until distribution. Cell percentages are combined by maximum across rows; +a spanning cell shares its percentage across the columns it covers. ### 3. Column widths calculation diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.bordercollapse.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.bordercollapse.md new file mode 100644 index 0000000..51db12a --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.bordercollapse.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md) > [borderCollapse](./heuristic-table-plugin.heuristictablepluginconfig.bordercollapse.md) + +## HeuristicTablePluginConfig.borderCollapse property + +Override the table's border model. When omitted, an inline `border-collapse` declaration from the table is used. + +**Signature:** + +```typescript +borderCollapse?: 'collapse' | 'separate'; +``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.getstyleforcell.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.getstyleforcell.md index 580cec5..f3f5572 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.getstyleforcell.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.getstyleforcell.md @@ -6,6 +6,8 @@ Customize cells appearance with this function. +Called once per cell per layout, with provisional widths measured from source styles. Returned styles are saved, included in the final layout, and reused for rendering. Width-dependent callbacks are not iterated. Keep this function referentially stable to avoid unnecessary layouts. + **Signature:** ```typescript @@ -42,7 +44,7 @@ cell -The cell for which styles should be provided. +The cell with its provisional width and constraints. diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md index 99d2254..c32be52 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md @@ -53,6 +53,25 @@ number _(Optional)_ The average advance width of one character, as a fraction of the font size, used to estimate how wide a cell's text is. + + + +[borderCollapse?](./heuristic-table-plugin.heuristictablepluginconfig.bordercollapse.md) + + + + + + + +'collapse' \| 'separate' + + + + +_(Optional)_ Override the table's border model. When omitted, an inline `border-collapse` declaration from the table is used. + + @@ -116,6 +135,8 @@ Description _(Optional)_ Customize cells appearance with this function. +Called once per cell per layout, with provisional widths measured from source styles. Returned styles are saved, included in the final layout, and reused for rendering. Width-dependent callbacks are not iterated. Keep this function referentially stable to avoid unnecessary layouts. + diff --git a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md index 292c469..f132b1c 100644 --- a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md +++ b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md @@ -52,6 +52,7 @@ export type FontWeightCoefficients = Record; // @public export interface HeuristicTablePluginConfig { baseFontCoeff?: number; + borderCollapse?: 'collapse' | 'separate'; fontWeightCoeffs?: FontWeightCoefficients; forceStretch?: boolean; getStyleForCell?(cell: TableCell): ViewStyle | null; diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index c06f8d6..10e6c4b 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -1,9 +1,8 @@ -import React, { memo, PropsWithChildren, useMemo } from 'react'; +import React, { memo, PropsWithChildren } from 'react'; import { ScrollView, View } from 'react-native'; import TreeRenderer from './TreeRenderer'; import { HTMLTableProps } from './shared-types'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; -import { getCollapsedTableBorderStyle } from './helpers/tableStyles'; export function shouldScrollTable( tableWidth: number, @@ -57,32 +56,7 @@ const HTMLTable = memo(function HTMLTable({ // down the tree. Sizing the container off `settings.contentWidth` instead // would spill the table out of every padded ancestor it sits in. const insets = layout.horizontalInsets; - const getStyleForCell = config.getStyleForCell; - // `getStyleForCell` is handed cells the layout only produces at the end of - // its own work, so the outer collapsed edge is narrowed once more here, now - // that every border the edge cells actually paint is known. Leaving it to - // the layout alone would let a border only the config declares lose to the - // weaker one source CSS resolved, and be painted by neither. The insets the - // layout measured against still come from source CSS: a config border - // changes what the table paints, not how wide it was laid out. - const tableBorderStyle = useMemo( - () => - layout.borderCollapse && getStyleForCell - ? getCollapsedTableBorderStyle( - { - cells: layout.cells, - maxX: layout.display.maxX, - maxY: layout.display.maxY - }, - layout.tableBorderStyle ?? {}, - (cell) => ({ - ...cell.tnode.styles.nativeBlockRet, - ...getStyleForCell(cell) - }) - ) - : layout.tableBorderStyle, - [getStyleForCell, layout] - ); + const tableBorderStyle = layout.tableBorderStyle; return ( + }} + > + availableWidth={layout.assignableWidth} + > {React.createElement(TreeRenderer, { node: layout.renderTree, config, + cellStyles: layout.cellStyles, borderCollapse: layout.borderCollapse, // Cells need the edge the wrapper resolved, not just their position // in the matrix: an outer boundary it leaves bare is still theirs. diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 7f3a9f4..9aa3df6 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -4,8 +4,7 @@ import { TNode } from '@native-html/render'; import computeColumnWidths from './helpers/computeColumnWidths'; import createRenderTree, { makeTableCells } from './helpers/createRenderTree'; import fillTableDisplay, { - createEmptyDisplay, - measureDisplay + createEmptyDisplay } from './helpers/fillTableDisplay'; import TCellConstraintsComputer from './helpers/TCellConstraintsComputer'; import { Display, Settings, TableCell, TableRoot } from './shared-types'; @@ -13,10 +12,10 @@ import extractColumnWidths from './helpers/extractColumnWidths'; import { clampWidth, resolveWidthConstraints } from './helpers/resolveWidth'; import resolveAvailableWidth from './helpers/resolveAvailableWidth'; import { getHorizontalInsets, getHorizontalMargins } from './helpers/measure'; -import { - getCollapsedTableBorderStyle, - resolveBorderCollapse -} from './helpers/tableStyles'; +import { resolveBorderCollapse } from './helpers/tableStyles'; +import resolveTableStyles, { + ResolvedCellStyle +} from './helpers/resolveTableStyles'; /** * Tables fill the width their containing block leaves them unless the config @@ -56,14 +55,8 @@ export default class TableLayout { * its ancestors and its own `max-width` allow. */ public readonly usedWidth: number; - /** - * Every cell of the table, at the width the columns resolved to. - * - * @remarks - * This is what {@link HeuristicTablePluginConfig.getStyleForCell} is called - * with, so it is the earliest point at which the styles that function - * contributes can take part in the collapsing border model. - */ + /** Resolved once and shared by layout and cell rendering. */ + public readonly cellStyles: ReadonlyMap; public readonly cells: TableCell[]; public readonly renderTree: TableRoot; constructor(tnode: TNode, config: Settings) { @@ -94,11 +87,7 @@ export default class TableLayout { const forceStretch = (config.forceStretch ?? DEFAULT_FORCE_STRETCH) || declaredTableWidth !== null; - // Cell coordinates and spans do not depend on the width the table resolves - // to; only their constraints do, and measuring text is the costly half of - // a layout pass. Laying the grid out first lets the collapsing model - // resolve the table's own borders — which feed the insets the cells are - // then measured against — without a second pass over the matrix. + // Build the grid once; styles may require a second measurement pass. const display = createEmptyDisplay({ ...config, // A table with a specified width distributes that width over its @@ -107,56 +96,62 @@ export default class TableLayout { forceStretch }); fillTableDisplay(tnode, display); - this.tableBorderStyle = this.borderCollapse - ? getCollapsedTableBorderStyle(display, style) - : null; - const effectiveTableStyle = this.tableBorderStyle - ? { ...style, ...this.tableBorderStyle } - : style; - const insets = getHorizontalInsets(effectiveTableStyle); - this.horizontalInsets = insets; - this.availableWidth = availableWidth; - // A table capped by `max-width` — or one whose declared width is narrower - // than its content demands — offers its columns less room than its - // ancestors leave it, and the excess has to be scrolled rather than - // spilled out of the box the table paints. - this.usedWidth = Math.max(0, Math.min(usedTableWidth, availableWidth)); - this.assignableWidth = Math.max(0, this.usedWidth - insets); - const layoutContentWidth = Math.max(0, usedTableWidth - insets); - display.contentWidth = layoutContentWidth; - measureDisplay( - display, - new TCellConstraintsComputer({ - contentWidth: layoutContentWidth, - baseFontCoeff: config.baseFontCoeff, - fontWeightCoeffs: config.fontWeightCoeffs - }) - ); - this.display = display; - // Declared column widths are independent of the width they will be - // resolved against, so the same set serves the min-width pass below. const declaredColumnWidths = extractColumnWidths(tnode); - let columnWidths = computeColumnWidths(this.display, declaredColumnWidths); - // A shrink-to-fit table may still not fall below its own `min-width`. When - // the content lands short of that floor, the columns share the floor - // rather than the width the content asked for. - const minLayoutWidth = Math.max(0, (minWidth ?? 0) - insets); - if (sum(columnWidths) < minLayoutWidth) { - const raisedColumnWidths = computeColumnWidths( - { ...this.display, contentWidth: minLayoutWidth, forceStretch: true }, - declaredColumnWidths + const configStyles = new Map(); + const measure = () => { + const resolved = resolveTableStyles( + display, + style, + this.borderCollapse, + configStyles ); - // Percentage columns resolve against whichever width the pass is given, - // so laying out against the floor can shrink them while a capped - // neighbour has no room left to absorb the slack. A floor may only - // widen the table, never narrow it. - if (sum(raisedColumnWidths) > sum(columnWidths)) { - columnWidths = raisedColumnWidths; + const insets = getHorizontalInsets({ + ...style, + ...resolved.tableBorderStyle + }); + display.contentWidth = Math.max(0, usedTableWidth - insets); + const computer = new TCellConstraintsComputer({ + contentWidth: display.contentWidth, + baseFontCoeff: config.baseFontCoeff, + fontWeightCoeffs: config.fontWeightCoeffs + }); + for (const cell of display.cells) { + cell.constraints = computer.computeCellConstraints( + cell.tnode, + resolved.cellStyles.get(cell.tnode)!.style + ); + } + let columnWidths = computeColumnWidths(display, declaredColumnWidths); + const minLayoutWidth = Math.max(0, (minWidth ?? 0) - insets); + if (sum(columnWidths) < minLayoutWidth) { + const raised = computeColumnWidths( + { ...display, contentWidth: minLayoutWidth, forceStretch: true }, + declaredColumnWidths + ); + if (sum(raised) > sum(columnWidths)) columnWidths = raised; } + return { ...resolved, insets, columnWidths }; + }; + let measured = measure(); + if (config.getStyleForCell) { + // Freeze callback results against provisional widths. Re-evaluating after + // each resize could oscillate for a callback that branches on width. + for (const cell of makeTableCells(display, measured.columnWidths)) { + const configured = config.getStyleForCell.call(null, cell); + configStyles.set(cell.tnode, configured ? { ...configured } : null); + } + measured = measure(); } - this.columnWidths = columnWidths; - this.totalWidth = sum(columnWidths); - this.cells = makeTableCells(this.display, this.columnWidths); + this.tableBorderStyle = measured.tableBorderStyle; + this.cellStyles = measured.cellStyles; + this.horizontalInsets = measured.insets; + this.availableWidth = availableWidth; + this.usedWidth = Math.max(0, Math.min(usedTableWidth, availableWidth)); + this.assignableWidth = Math.max(0, this.usedWidth - measured.insets); + this.display = display; + this.columnWidths = measured.columnWidths; + this.totalWidth = sum(this.columnWidths); + this.cells = makeTableCells(display, this.columnWidths); this.renderTree = createRenderTree(this.cells); } } diff --git a/packages/heuristic-table-plugin/src/TreeRenderer.tsx b/packages/heuristic-table-plugin/src/TreeRenderer.tsx index 8b6f0dc..fd290a9 100644 --- a/packages/heuristic-table-plugin/src/TreeRenderer.tsx +++ b/packages/heuristic-table-plugin/src/TreeRenderer.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { StyleSheet, View, ViewStyle } from 'react-native'; -import { TNodeRenderer } from '@native-html/render'; +import { TNode, TNodeRenderer } from '@native-html/render'; +import { ResolvedCellStyle } from './helpers/resolveTableStyles'; import { HeuristicTablePluginConfig, TableRenderNode } from './shared-types'; const styles = StyleSheet.create({ @@ -11,6 +12,7 @@ const styles = StyleSheet.create({ export default function TreeRenderer({ node, config, + cellStyles, borderCollapse, tableBorderStyle, maxX, @@ -22,6 +24,7 @@ export default function TreeRenderer({ renderIndex: number; renderLength: number; config?: HeuristicTablePluginConfig; + cellStyles: ReadonlyMap; borderCollapse: boolean; tableBorderStyle: ViewStyle | null; maxX: number; @@ -38,6 +41,7 @@ export default function TreeRenderer({ cell: node, collapsedMarginTop: null, config, + resolvedCellStyle: cellStyles.get(node.tnode), borderCollapse, tableBorderStyle, maxX, @@ -55,6 +59,7 @@ export default function TreeRenderer({ node: v, key: i, config, + cellStyles, borderCollapse, tableBorderStyle, maxX, @@ -73,6 +78,7 @@ export default function TreeRenderer({ node: v, key: i, config, + cellStyles, borderCollapse, tableBorderStyle, maxX, diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index c2646a5..f07e047 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -1,30 +1,21 @@ -import pipe from 'ramda/src/pipe'; -import sum from 'ramda/src/sum'; -import map from 'ramda/src/map'; -import max from 'ramda/src/max'; -import reduce from 'ramda/src/reduce'; +import { ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; -import { TCellConstraints, TConstraintsBase } from '../shared-types'; +import { TCellConstraints } from '../shared-types'; import { getHorizontalInsets, getHorizontalMargins } from './measure'; import { getPaintedBlockStyle } from './tableStyles'; -import { resolveCssSize, resolveImposedWidth } from './resolveWidth'; +import { + resolveCssSize, + resolveImposedWidth, + resolvePercentage +} from './resolveWidth'; interface TextChunkStats { fontWeightCoeff: number; fontFamilyCoeff: number; fontSize: number; - characters: number; - maxWordLength: number; + text: string; } -/** - * Distinction between two types of content generating constraints. - * - * - Blocks. When blocks such as images have an explicit width, this width is - * used as minimum and prefered width for this tnode cell. - * - TPhrasing. Phrasing content will provide minimum and prefered width up to approx 10 characters. Above that, - * each new character will augment prefered width logarathmically, since text wraps nicely. - */ interface TCellStats { /** * The cell's own horizontal insets: its padding and border. Margins are @@ -46,20 +37,15 @@ interface TCellStats { /** * Text stats in this cell. */ - textStats: TextChunkStats[]; + textStats: TextChunkStats[][]; } -function getInitCellStatsForTnode(tnode: TNode): TCellStats { +function getInitCellStats(style: ViewStyle): TCellStats { return { blockWidth: 0, cellBoxWidth: null, - // The padding a cell gets from the user-agent stylesheet is space its - // content cannot use, exactly like a declared one, so the intrinsic widths - // reserve it here as well as the cell renderer paints it. Config styles - // take no part in this pass, so a padding only `getStyleForCell` declares - // stays measured as the default it replaces. - horizontalSpace: getHorizontalInsets(getPaintedBlockStyle(tnode)), - textStats: [] + horizontalSpace: getHorizontalInsets(style), + textStats: [[]] }; } @@ -86,34 +72,6 @@ function isDigit(character: string | undefined): boolean { return character !== undefined && DIGIT_REGEX.test(character); } -function getMaxUnbreakableTextLength(text: string): number { - const characters = Array.from(text); - let currentLength = 0; - let maxLength = 0; - for (let i = 0; i < characters.length; i++) { - const character = characters[i] as string; - if (isBreakingSpace(character)) { - currentLength = 0; - continue; - } - currentLength += character.length; - maxLength = Math.max(maxLength, currentLength); - // A line can break after a regular hyphen, but never between two digits - // (UAX #14 LB25) — that would split `2026-09-03` or a phone number across - // two lines. Keep the hyphen in the preceding segment because it still - // occupies space at the line end. U+2011 NON-BREAKING HYPHEN is - // deliberately not included. - const isHyphen = character === '-' || character === '\u2010'; - if ( - isHyphen && - !(isDigit(characters[i - 1]) && isDigit(characters[i + 1])) - ) { - currentLength = 0; - } - } - return maxLength; -} - /** * How much wider text renders at a given font weight than at a regular one. * @@ -182,18 +140,6 @@ export default class TCellConstraintsComputer { this.contentWidth = contentWidth ?? 0; } - private getContentDensity = pipe( - map((ch) => ch.characters * this.getTextCoeff(ch)), - sum - ); - - private geTextMinWidth = pipe( - map( - (ch) => ch.maxWordLength * this.getTextCoeff(ch) - ), - reduce(max, 0) - ); - private getTextCoeff(ch: TextChunkStats): number { return ( ch.fontFamilyCoeff * ch.fontSize * this.baseFontCoeff * ch.fontWeightCoeff @@ -202,26 +148,33 @@ export default class TCellConstraintsComputer { private assembleCellStats( tnode: TNode, - stats: TCellStats = getInitCellStatsForTnode(tnode), - isCellRoot = true + stats: TCellStats, + cellStyle?: ViewStyle ): TCellStats { - if (tnode.type === 'text') { + if (tnode.tagName === 'br') { + stats.textStats.push([]); + } else if (tnode.type === 'text') { const fontSize = tnode.styles.nativeTextFlow.fontSize ?? this.fallbackFontSize; const fontWeight = tnode.styles.nativeTextFlow.fontWeight ?? 'normal'; const fontWeightCoeff = this.fontWeightCoeffs[String(fontWeight)] ?? 1; - stats.textStats.push({ - characters: tnode.data.length, - maxWordLength: getMaxUnbreakableTextLength(tnode.data), + stats.textStats[stats.textStats.length - 1]!.push({ + text: tnode.data, fontFamilyCoeff: 1, fontSize, fontWeightCoeff }); } else { + // Inline wrappers do not introduce a word boundary. Blocks and explicit + // line breaks separate text on both sides of their contents. + const separatesText = tnode.type === 'block'; + if (separatesText) { + stats.textStats.push([]); + } if (tnode.type === 'block') { - const width = this.resolveBlockWidth(tnode, isCellRoot); + const width = this.resolveBlockWidth(tnode, cellStyle); if (width !== null) { - if (isCellRoot) { + if (cellStyle) { // React Native lays out with `box-sizing: border-box`, and CSS // gives a table cell that same box model, so the width a cell // declares already holds its padding and border. It is kept apart @@ -236,7 +189,10 @@ export default class TCellConstraintsComputer { } } } - tnode.children.forEach((n) => this.assembleCellStats(n, stats, false)); + tnode.children.forEach((n) => this.assembleCellStats(n, stats)); + if (separatesText) { + stats.textStats.push([]); + } } return stats; } @@ -253,34 +209,66 @@ export default class TCellConstraintsComputer { * presentational `width` attribute is consulted last, as befits a hint of the * lowest priority. */ - private resolveBlockWidth(tnode: TNode, isCellRoot: boolean): number | null { + private resolveBlockWidth(tnode: TNode, style?: ViewStyle): number | null { return resolveImposedWidth(tnode, this.contentWidth, { - // The cell's percentage width resolves against the table. A descendant's - // percentage resolves against the eventual cell content box, which is - // precisely what this intrinsic-width pass is still trying to discover. - resolvePercentages: isCellRoot + // Cell percentages are preferences reconciled during column distribution. + // Descendant percentages depend on the as-yet unknown cell content box. + resolvePercentages: false, + style }); } - private computeTextConstraints(chunks: TextChunkStats[]): TConstraintsBase { - const minWidth = this.geTextMinWidth(chunks); - const contentDensity = this.getContentDensity(chunks); - return { - minWidth, - contentDensity - }; + private computeTextConstraints(runs: TextChunkStats[][]) { + let minWidth = 0; + let contentDensity = 0; + let maxWidth = 0; + for (const chunks of runs) { + const characters = chunks.flatMap((chunk) => + Array.from(chunk.text, (character) => ({ + character, + width: character.length * this.getTextCoeff(chunk) + })) + ); + let wordWidth = 0; + let lineWidth = 0; + for (let i = 0; i < characters.length; i++) { + const { character, width } = characters[i]!; + contentDensity += width; + lineWidth += width; + if (isBreakingSpace(character)) { + wordWidth = 0; + continue; + } + wordWidth += width; + minWidth = Math.max(minWidth, wordWidth); + // Keep numeric hyphens unbroken even when adjacent digits belong to + // different styled nodes. Other hyphens stay in the preceding word. + const isHyphen = character === '-' || character === '\u2010'; + if ( + isHyphen && + !( + isDigit(characters[i - 1]?.character) && + isDigit(characters[i + 1]?.character) + ) + ) { + wordWidth = 0; + } + } + maxWidth = Math.max(maxWidth, lineWidth); + } + return { minWidth, maxWidth, contentDensity }; } - computeCellConstraints(tnode: TNode): TCellConstraints { - const stats = this.assembleCellStats(tnode); + computeCellConstraints( + tnode: TNode, + style: ViewStyle = getPaintedBlockStyle(tnode) + ): TCellConstraints { + const stats = this.assembleCellStats(tnode, getInitCellStats(style), style); const blockWidth = stats.blockWidth; const textConstrains = this.computeTextConstraints(stats.textStats); // A `max-width` on the cell itself caps the whole cell box. A descendant's // `max-width` must not, since it only bounds that descendant. - const cellMaxWidth = resolveCssSize( - tnode.styles.nativeBlockRet.maxWidth, - this.contentWidth - ); + const cellMaxWidth = resolveCssSize(style.maxWidth, this.contentWidth); // Per CSS 2.1 §17.5.2.2, "if the specified 'width' (W) of the cell is // greater than MCW, W is the minimum cell width", and the maximum cell // width is likewise raised by the column 'width'. So an explicit width @@ -293,11 +281,22 @@ export default class TCellConstraintsComputer { cellBoxWidth ); const maxWidth = Math.max( - Math.max(blockWidth, textConstrains.contentDensity) + - stats.horizontalSpace, + Math.max(blockWidth, textConstrains.maxWidth) + stats.horizontalSpace, cellBoxWidth ); + const percentage = resolvePercentage(style.width ?? tnode.attributes.width); + const percentWidth = + percentage === null + ? null + : Math.min( + percentage, + resolvePercentage(style.maxWidth) ?? percentage, + cellMaxWidth === null || this.contentWidth === 0 + ? percentage + : cellMaxWidth / this.contentWidth + ); return { + ...(percentWidth === null ? {} : { percentWidth }), minWidth, // `max-width` caps the width the cell would *like*, but never takes it // below the width it needs to hold its longest word: min-content is a diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx b/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx new file mode 100644 index 0000000..9794d78 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx @@ -0,0 +1,92 @@ +import React, { PropsWithChildren } from 'react'; +import { render } from '@testing-library/react-native'; +import { ScrollView, View, ViewStyle } from 'react-native'; +import HTMLTable from '../../HTMLTable'; +import TableLayout from '../../TableLayout'; +import { HTMLTableProps } from '../../shared-types'; +import { createTableTNode } from './utils'; + +// Inspect the real table wrapper and scroll container independently of cell rendering. +jest.mock('../../TreeRenderer', () => () => null); + +function DefaultRenderer({ + children, + style +}: PropsWithChildren<{ style: ViewStyle }>) { + return ( + + {children} + + ); +} + +function renderTable(html: string, contentWidth: number) { + const tnode = createTableTNode(html); + const settings = { contentWidth, forceStretch: false }; + const layout = new TableLayout(tnode, settings); + const props = { + tnode, + layout, + settings, + config: settings, + style: tnode.styles.nativeBlockRet, + TDefaultRenderer: DefaultRenderer + } as unknown as HTMLTableProps; + return render(); +} + +describe('HTMLTable containers', () => { + it('uses the capped table width for the wrapper and overflow viewport', () => { + const rendered = renderTable( + '
AB
', + 600 + ); + expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ width: 300 }); + const scroll = rendered.UNSAFE_getByType(ScrollView); + expect(scroll.props.horizontal).toBe(true); + expect(scroll.props.style).toEqual({ width: 300 }); + expect(scroll.props.contentContainerStyle).toEqual({ width: 600 }); + }); + + it.each([ + [ + '
A
', + 30 + ], + [ + '
A
', + 10 + ] + ] as const)( + 'clamps the painted wrapper when its insets exceed its width: %s', + (html, width) => { + const rendered = renderTable(html, 400); + expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ width }); + expect(rendered.UNSAFE_getByType(ScrollView).props.style).toEqual({ + width: 0 + }); + } + ); + + it('paints a shrink-to-fit wrapper with its own insets', () => { + const rendered = renderTable( + '
A
', + 400 + ); + expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ width: 120 }); + expect(rendered.UNSAFE_queryByType(ScrollView)).toBeNull(); + }); + + it.each([100, 100.5, 101, 102])( + 'scrolls only when the %spx content exceeds the viewport by more than one pixel', + (width) => { + const rendered = renderTable( + `
A
`, + 100 + ); + expect(rendered.UNSAFE_queryByType(ScrollView) !== null).toBe( + width > 101 + ); + } + ); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index 6f8cd89..656207d 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -123,8 +123,9 @@ describe('TCellConstraintsComputer', () => { ); }); - it('should still break a hyphen with a digit on only one side', () => { - // "ISO-" is the longest segment; the digits stand alone after the break. + it('uses the plugin heuristic to break ISO-2026 into two segments', () => { + // This heuristic differs from default UAX #14 LB25 (HY × NU). + // Both "ISO-" and "2026" have four characters. const { minWidth } = constraintsFor('ISO-2026'); expect(minWidth).toBeCloseTo( @@ -150,6 +151,62 @@ describe('TCellConstraintsComputer', () => { }); }); + describe('words spanning inline nodes', () => { + it.each([ + 'fnejfeaf', + 'fnejfeaf' + ])('should measure %s as one eight-character word', (markup) => { + const actual = constraintsFor(`${markup}`); + const expected = constraintsFor('fnejfeaf'); + expect(actual.minWidth).toBeCloseTo(expected.minWidth); + expect(actual.maxWidth).toBeCloseTo(expected.maxWidth); + }); + + it('should sum the widths of differently styled word fragments', () => { + const { minWidth, maxWidth } = constraintsFor( + 'fnejfeaf' + ); + const width = + DEFAULT_HORIZONTAL_PADDING + + 4 * BASE_FONT_COEFF * (14 + 20 * DEFAULT_FONT_WEIGHT_COEFFS.bold!); + expect(minWidth).toBeCloseTo(width); + expect(maxWidth).toBeCloseTo(width); + }); + + it.each([ + ['fnej feaf', 'fnej feaf'], + ['fnej feaf', 'fnej feaf'], + ['10 000 km', '10 000 km'], + ['2026-09-03', '2026-09-03'], + ['Medium-High', 'Medium-High'] + ])('should preserve break opportunities in %s', (markup, plain) => { + const actual = constraintsFor(`${markup}`); + const expected = constraintsFor(`${plain}`); + expect(actual.minWidth).toBeCloseTo(expected.minWidth); + expect(actual.maxWidth).toBeCloseTo(expected.maxWidth); + }); + + it.each([ + 'fnej
feaf', + 'fnej
feaf
abcd', + '
fnej
feaf
' + ])('should keep separate lines from joining in %s', (markup) => { + expect(constraintsFor(`${markup}`).minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 4 * 14 * BASE_FONT_COEFF + ); + }); + + it('should keep max-width from clipping a word spanning nodes', () => { + const { minWidth, maxWidth } = constraintsFor( + 'fnejfeaf' + ); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 8 * 14 * BASE_FONT_COEFF + ); + expect(maxWidth).toBe(minWidth); + }); + }); + describe('default cell padding', () => { it('should reserve the padding a bare cell gets from the user agent', () => { const bare = constraintsFor('Method'); @@ -198,25 +255,23 @@ describe('TCellConstraintsComputer', () => { }); describe('width resolution', () => { - it('should resolve a percentage width against the containing block', () => { - // 50% of a 400px containing block, which a browser resolves against the - // table — not discarded for want of being a number. - const { minWidth } = constraintsFor('a'); - expect(minWidth).toBeGreaterThanOrEqual(200); - expect(minWidth).toBeLessThan(220); + it('should keep a percentage width as a preference, not an intrinsic floor', () => { + const { minWidth, percentWidth } = constraintsFor( + 'a' + ); + expect(percentWidth).toBe(0.5); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 14 * BASE_FONT_COEFF + ); }); it('should not resolve a descendant percentage against the table', () => { const { minWidth } = constraintsFor( '
a
' ); - expect(minWidth).toBeLessThan(50); - }); - - it('should honour an absolute width', () => { - const { minWidth } = constraintsFor('a'); - expect(minWidth).toBeGreaterThanOrEqual(200); - expect(minWidth).toBeLessThan(220); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 14 * BASE_FONT_COEFF + ); }); it('should treat a declared cell width as a border-box one', () => { @@ -241,8 +296,7 @@ describe('TCellConstraintsComputer', () => { it('should read the presentational width attribute', () => { const { minWidth } = constraintsFor('a'); - expect(minWidth).toBeGreaterThanOrEqual(200); - expect(minWidth).toBeLessThan(220); + expect(minWidth).toBe(200); }); it('should let a CSS width supersede the presentational attribute', () => { @@ -250,20 +304,16 @@ describe('TCellConstraintsComputer', () => { const { minWidth } = constraintsFor( 'a' ); - expect(minWidth).toBeGreaterThanOrEqual(100); - expect(minWidth).toBeLessThan(120); - }); - - it('should ignore a width it cannot resolve', () => { - const { minWidth } = constraintsFor('a'); - expect(minWidth).toBeLessThan(50); + expect(minWidth).toBe(100); }); it('should let CSS auto override the presentational width attribute', () => { const { minWidth } = constraintsFor( 'a' ); - expect(minWidth).toBeLessThan(50); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 14 * BASE_FONT_COEFF + ); }); }); @@ -272,15 +322,14 @@ describe('TCellConstraintsComputer', () => { const { minWidth } = constraintsFor( 'a' ); - expect(minWidth).toBeGreaterThanOrEqual(300); + expect(minWidth).toBe(300); }); it('should cut a width down to max-width', () => { const { minWidth } = constraintsFor( 'a' ); - expect(minWidth).toBeGreaterThanOrEqual(100); - expect(minWidth).toBeLessThan(150); + expect(minWidth).toBe(100); }); it('should let min-width win over a smaller max-width', () => { @@ -288,30 +337,55 @@ describe('TCellConstraintsComputer', () => { const { minWidth } = constraintsFor( 'a' ); - expect(minWidth).toBeGreaterThanOrEqual(200); + expect(minWidth).toBe(200); }); it('should apply min-width on its own, without a width', () => { const { minWidth } = constraintsFor('a'); - expect(minWidth).toBeGreaterThanOrEqual(250); + expect(minWidth).toBe(250); }); }); describe('maximum cell width', () => { + it.each([ + 'AAAA
BBBB', + '
AAAA
BBBB
', + 'AAAA
BBBB
' + ])( + 'uses the widest forced line in %s without losing text density', + (markup) => { + const actual = constraintsFor(`${markup}`); + const line = constraintsFor('AAAA'); + expect(actual.maxWidth).toBeCloseTo(line.maxWidth); + expect(actual.contentDensity).toBeCloseTo(2 * line.contentDensity); + } + ); + + it('keeps styled fragments together when computing the widest line', () => { + const actual = constraintsFor( + 'ABCD
E' + ); + expect(actual.maxWidth).toBeCloseTo(2 + 2 * BASE_FONT_COEFF * (14 + 20)); + }); + it('should cap the maximum width at max-width', () => { const { maxWidth } = constraintsFor( `${'lorem ipsum '.repeat(20)}` ); - expect(maxWidth).toBeLessThanOrEqual(100); + expect(maxWidth).toBe(100); }); - it('should never report a maximum below the minimum', () => { + it('preserves the full unbreakable word when max-width is smaller', () => { // A cap tighter than the longest word must not drive the cell below the // width it needs to hold that word. const { minWidth, maxWidth } = constraintsFor( 'antidisestablishmentarianism' ); - expect(maxWidth).toBeGreaterThanOrEqual(minWidth); + expect(minWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + + 'antidisestablishmentarianism'.length * 14 * BASE_FONT_COEFF + ); + expect(maxWidth).toBe(minWidth); }); it('should keep an explicitly sized block from collapsing the cell', () => { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts index 92151ea..40524f5 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts @@ -1,6 +1,7 @@ import TableLayout from '../../TableLayout'; import { shouldScrollTable } from '../../HTMLTable'; import { Settings } from '../../shared-types'; +import reduceColumnConstraints from '../reduceColumnConstraints'; import { createTableTNode } from './utils'; function layoutFor(html: string, settings: Settings): TableLayout { @@ -68,13 +69,14 @@ describe('TableLayout', () => { it('should expand a colgroup span when it has no col children', () => { const { columnWidths } = layoutFor( - ` - + `
+ +
ABC
`, { contentWidth: 400, forceStretch: false } ); - expect(columnWidths).toEqual([100, 100, 100]); + expect(columnWidths).toEqual([100, 100, 200]); }); it('should prefer a CSS col width over its HTML width attribute', () => { @@ -339,23 +341,26 @@ describe('TableLayout', () => { expect(totalWidth).toBeCloseTo(400); }); - it('should give every column a share of the surplus', () => { + it('should grow every column with room beyond its minimum', () => { // The narrowest column used to be pinned at its minimum, because the // weights were taken relative to the least dense column. - const { columnWidths } = layoutFor( + const { columnWidths, display, totalWidth } = layoutFor( ` - +
11 2 a somewhat longer cell of text an even longer cell of text than the one before it
`, { contentWidth: 600, forceStretch: false } ); - const [first, second, third] = columnWidths as [number, number, number]; - expect(first).toBeGreaterThan(0); - expect(second).toBeGreaterThan(first); - expect(third).toBeGreaterThan(second); + const constraints = reduceColumnConstraints(display.cells); + constraints.forEach(({ minWidth, spread }, index) => { + expect(spread).toBeGreaterThan(minWidth); + expect(columnWidths[index]).toBeGreaterThan(minWidth); + expect(columnWidths[index]).toBeLessThanOrEqual(spread); + }); + expect(totalWidth).toBeCloseTo(600); }); it('should never exceed the container width when it fits', () => { @@ -369,19 +374,17 @@ describe('TableLayout', () => { expect(totalWidth).toBeLessThanOrEqual(500); }); - it('should place a cell after a rowspan+colspan rectangle end to end', () => { - const { display } = layoutFor( - ` - - -
AB
C
`, - { contentWidth: 400, forceStretch: false } + it('passes configured font coefficients through to column measurement', () => { + const { columnWidths } = layoutFor( + '
AAAAAAAA
', + { + contentWidth: 400, + forceStretch: false, + baseFontCoeff: 0.5, + fontWeightCoeffs: { bold: 2 } + } ); - expect(display.cells).toMatchObject([ - { x: 0, y: 0, lenX: 2, lenY: 2 }, - { x: 2, y: 0 }, - { x: 2, y: 1 } - ]); + expect(columnWidths).toEqual([40, 80]); }); describe('containing block', () => { @@ -477,10 +480,10 @@ describe('TableLayout', () => { `; const body = `${cols}AB`; - const { totalWidth: without } = layoutFor( - `${body}
`, - { contentWidth: 600, forceStretch: false } - ); + const { totalWidth: without } = layoutFor(`${body}
`, { + contentWidth: 600, + forceStretch: false + }); const { totalWidth: with400 } = layoutFor( `${body}
`, { contentWidth: 600, forceStretch: false } @@ -488,7 +491,7 @@ describe('TableLayout', () => { expect(with400).toBeGreaterThanOrEqual(without); }); - it('should scroll the columns that overflow the table max-width', () => { + it('reports the column overflow beyond the table max-width', () => { // The cells demand 600px inside a table that paints only 300px, so the // surplus belongs to a horizontal scroller rather than spilling out. const { totalWidth, assignableWidth } = layoutFor( @@ -502,7 +505,7 @@ describe('TableLayout', () => { expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(true); }); - it('should not paint a table wider than the room its container leaves', () => { + it('caps usedWidth at the containing width when padding overflows', () => { // The insets were added back after the assignable width had been // floored at zero, so a table whose padding alone overflows its // container painted a box wider than the room it was given. @@ -516,7 +519,7 @@ describe('TableLayout', () => { expect(usedWidth).toBe(30); }); - it('should not paint a table past its own max-width', () => { + it('caps usedWidth at max-width when padding overflows', () => { const { usedWidth } = layoutFor( '
A
', { contentWidth: 400, forceStretch: true } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts index 4d4f1db..52c4284 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts @@ -3,7 +3,9 @@ import { createEmptyDisplay } from '../fillTableDisplay'; import { Display, DisplayCell, TCellConstraints } from '../../shared-types'; function makeDisplay( - cells: Array & { constraints: TCellConstraints }>, + cells: Array< + Pick & { constraints: TCellConstraints } + >, settings: { contentWidth: number; forceStretch?: boolean } ): Display { const display = createEmptyDisplay(settings); @@ -17,7 +19,7 @@ function makeDisplay( } describe('computeColumnWidths', () => { - it('should never shrink a column below its minimum width, even when its maximum width is smaller', () => { + it('preserves a fixed-width column beside a column with more content', () => { // An icon column: a single wide glyph (`width: 40px` plus 11px of padding // and borders) whose one character makes for a very low content density. // CSS 2.1 §17.5.2.2 raises both the column minimum and maximum by the @@ -72,7 +74,9 @@ describe('computeColumnWidths', () => { ); widths.forEach((width, i) => { expect(width).toBeLessThanOrEqual(constraints[i]!.constraints.maxWidth); - expect(width).toBeGreaterThanOrEqual(constraints[i]!.constraints.minWidth); + expect(width).toBeGreaterThanOrEqual( + constraints[i]!.constraints.minWidth + ); }); // The surplus is fully used: the table fills its container. expect(widths.reduce((a, b) => a + b, 0)).toBeCloseTo(400); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts new file mode 100644 index 0000000..44f68b3 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts @@ -0,0 +1,291 @@ +import { CustomRendererProps, TBlock } from '@native-html/render'; +import { ViewStyle } from 'react-native'; +import TableLayout from '../../TableLayout'; +import useHtmlTableCellProps from '../../useHtmlTableCellProps'; +import { shouldScrollTable } from '../../HTMLTable'; +import { Settings } from '../../shared-types'; +import { createTableTNode } from './utils'; + +function layoutFor(html: string, settings: Partial = {}) { + return new TableLayout(createTableTNode(html), { + contentWidth: 100, + forceStretch: false, + ...settings + }); +} + +function paintedStyle(layout: TableLayout, index: number): ViewStyle { + const cell = layout.cells[index]!; + return useHtmlTableCellProps({ + tnode: cell.tnode, + style: cell.tnode.styles.nativeBlockRet, + propsFromParent: { + cell, + config: layout.display, + resolvedCellStyle: layout.cellStyles.get(cell.tnode) + } + } as unknown as CustomRendererProps).style as ViewStyle; +} + +describe('layout and painted cell styles', () => { + it('paints a neighbour-only left border once on the preceding cell', () => { + const layout = layoutFor(` + +
AB
`); + expect(paintedStyle(layout, 0)).toMatchObject({ + borderRightWidth: 5, + borderRightColor: 'red' + }); + expect(paintedStyle(layout, 1)).toMatchObject({ borderLeftWidth: 0 }); + expect(layout.columnWidths).toEqual([16.1, 11.1]); + }); + + it('paints a neighbour-only top border once on the preceding row', () => { + const layout = layoutFor(` + +
A
B
`); + expect(paintedStyle(layout, 0)).toMatchObject({ + borderBottomWidth: 5, + borderBottomColor: 'red' + }); + expect(paintedStyle(layout, 1)).toMatchObject({ borderTopWidth: 0 }); + }); + + it('does not copy a cell left border onto its unrelated right edge', () => { + const layout = layoutFor(` + +
AB
`); + expect(paintedStyle(layout, 0).borderRightWidth).toBe(0); + expect(layout.tableBorderStyle?.borderLeftWidth).toBe(5); + }); + + it('compares all neighbours touching a rowspan, excluding unrelated rows', () => { + const layout = layoutFor(` + + + +
AB
C
DE
`); + expect(paintedStyle(layout, 0)).toMatchObject({ + borderRightWidth: 5, + borderRightColor: 'blue' + }); + }); + + it('compares neighbours below a colspan', () => { + const layout = layoutFor(` + + +
A
BC
`); + expect(paintedStyle(layout, 0)).toMatchObject({ + borderBottomWidth: 5, + borderBottomColor: 'blue' + }); + }); + + it.each(['dashed', 'dotted'] as const)( + 'preserves an exclusively %s frame', + (borderStyle) => { + const layout = layoutFor(` + +
A
`); + expect(layout.tableBorderStyle).toMatchObject({ + borderStyle, + borderLeftWidth: 2 + }); + } + ); + + it('takes the style of a wider winner rather than a weaker solid table border', () => { + const layout = + layoutFor(` + +
A
`); + expect(layout.tableBorderStyle?.borderStyle).toBe('dashed'); + }); + + it('takes the winning style and color of an interior neighbour', () => { + const layout = layoutFor(` + +
AB
`); + expect(paintedStyle(layout, 0)).toMatchObject({ + borderStyle: 'dashed', + borderRightWidth: 5, + borderRightColor: 'red' + }); + }); + + it('reserves collapsed outer borders only in the wrapper', () => { + const layout = layoutFor( + ` + +
A
`, + { contentWidth: 30 } + ); + expect(layout.horizontalInsets).toBe(20); + expect(layout.totalWidth).toBeCloseTo(9.1); + expect(shouldScrollTable(layout.totalWidth, layout.assignableWidth)).toBe( + false + ); + expect(paintedStyle(layout, 0)).toMatchObject({ + borderLeftWidth: 0, + borderRightWidth: 0 + }); + }); + + it('still reserves both cell borders in separate mode', () => { + const layout = layoutFor( + '
A
' + ); + expect(layout.horizontalInsets).toBe(0); + expect(layout.totalWidth).toBeCloseTo(29.1); + }); + + it.each([ + [{ padding: 8 }, 16], + [{ paddingHorizontal: 8 }, 16], + [{ paddingLeft: 8 }, 9], + [{ padding: 0 }, 0], + [{ paddingStart: 8 }, 8], + [{ padding: 8, paddingLeft: 2 }, 10], + [{ padding: 8, borderWidth: 3 }, 22] + ] as [ViewStyle, number][])( + 'measures callback style %j and reuses it when painting', + (style, insets) => { + const getStyleForCell = jest.fn(() => style); + const layout = layoutFor('
A
', { + getStyleForCell + }); + expect(layout.totalWidth).toBeCloseTo(9.1 + insets); + expect(paintedStyle(layout, 0)).toMatchObject(style); + expect(getStyleForCell).toHaveBeenCalledTimes(1); + } + ); + + it('lets source longhands keep precedence over callback shorthands in both passes', () => { + const layout = layoutFor( + '
A
', + { + getStyleForCell: () => ({ padding: 8 }) + } + ); + expect(layout.totalWidth).toBeCloseTo(9.1 + 4 + 8); + expect(paintedStyle(layout, 0)).toMatchObject({ + paddingLeft: 4, + padding: 8 + }); + }); + + it('uses callback borders for both outer and neighbouring collapsed edges', () => { + const getStyleForCell = jest.fn((cell) => + cell.x === 1 ? { borderWidth: 5, borderColor: 'red' } : null + ); + const layout = layoutFor( + '
AB
', + { getStyleForCell } + ); + expect(layout.horizontalInsets).toBe(5); + expect(layout.totalWidth).toBeCloseTo(2 * 11.1 + 5); + expect(paintedStyle(layout, 0)).toMatchObject({ + borderRightWidth: 5, + borderRightColor: 'red' + }); + expect(paintedStyle(layout, 1)).toMatchObject({ + borderLeftWidth: 0, + borderRightWidth: 0 + }); + expect(getStyleForCell).toHaveBeenCalledTimes(2); + }); + + it('allows a callback to remove source borders before resolving the wrapper', () => { + const layout = layoutFor( + '
A
', + { + getStyleForCell: () => ({ + borderLeftWidth: 0, + borderRightWidth: 0, + borderTopWidth: 0, + borderBottomWidth: 0 + }) + } + ); + expect(layout.horizontalInsets).toBe(0); + expect(layout.totalWidth).toBeCloseTo(11.1); + }); + + it('freezes width-dependent callback results instead of oscillating', () => { + const getStyleForCell = jest.fn((cell) => ({ + padding: cell.width < 20 ? 10 : 0 + })); + const layout = layoutFor('
A
', { + getStyleForCell + }); + expect(getStyleForCell.mock.calls[0]![0].width).toBeCloseTo(11.1); + expect(layout.totalWidth).toBeCloseTo(29.1); + expect(paintedStyle(layout, 0).padding).toBe(10); + expect(getStyleForCell).toHaveBeenCalledTimes(1); + }); + + it('scrolls when callback padding makes content exceed available space', () => { + const layout = layoutFor('
A
', { + contentWidth: 20, + getStyleForCell: () => ({ padding: 8 }) + }); + expect(shouldScrollTable(layout.totalWidth, layout.assignableWidth)).toBe( + true + ); + }); +}); + +describe('cell percentage distribution', () => { + it.each(['style="width:80%"', 'width="80%"'])( + 'reconciles %s like column percentages', + (declaration) => { + const cells = layoutFor( + `
AB
` + ); + const columns = layoutFor( + `
AB
` + ); + expect(cells.columnWidths).toEqual(columns.columnWidths); + expect(cells.totalWidth).toBeCloseTo(100); + } + ); + + it('keeps unbreakable content as a floor even when percentages cannot fit', () => { + const layout = layoutFor( + '
AAAAAAAAAAAAB
' + ); + expect(layout.columnWidths[0]).toBeCloseTo(12 * 9.1 + 2); + expect(shouldScrollTable(layout.totalWidth, layout.assignableWidth)).toBe( + true + ); + }); + + it('merges percentages across rows with a maximum', () => { + const layout = layoutFor( + '
AB
CD
' + ); + expect(layout.columnWidths[0]).toBeCloseTo(80); + }); + + it('splits a colspan percentage across its columns', () => { + const layout = layoutFor( + '
AB
CDE
' + ); + expect(layout.columnWidths).toEqual([40, 40, 11.1]); + }); + + it('retains absolute column floors when a cell contributes a percentage', () => { + const layout = layoutFor( + '
AB
' + ); + expect(layout.columnWidths[0]).toBe(70); + }); + + it('honours CSS auto over a percentage attribute', () => { + const layout = layoutFor( + '
AB
' + ); + expect(layout.totalWidth).toBeCloseTo(22.2); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts index 608500e..ecdf123 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts @@ -1,6 +1,20 @@ import reduceColumnConstraints from '../reduceColumnConstraints'; -describe('getColumnConstraints', () => { +describe('reduceColumnConstraints', () => { + it('raises a maximum below its minimum to the minimum', () => { + expect( + reduceColumnConstraints([ + { + x: 0, + y: 0, + lenX: 1, + lenY: 1, + constraints: { minWidth: 51, maxWidth: 30, contentDensity: 10 } + } + ]) + ).toEqual([{ minWidth: 51, spread: 51, contentDensity: 10 }]); + }); + it('should return a record which keys are column indexes, and which values are the reduced constraints for this column', () => { expect( reduceColumnConstraints([ diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts index f863306..c5b2366 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts @@ -1,10 +1,7 @@ import relaxHeightConstraint from '../relaxHeightConstraint'; describe('relaxHeightConstraint', () => { - it('should translate an explicit height to a minimum height', () => { - expect(relaxHeightConstraint({ height: 48 })).toEqual({ minHeight: 48 }); - }); - it('should preserve unrelated styles', () => { + it('translates height to minHeight while preserving unrelated styles', () => { expect( relaxHeightConstraint({ height: 48, backgroundColor: 'red' }) ).toEqual({ diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx b/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx new file mode 100644 index 0000000..4b1d8f3 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import RenderHTML from '@native-html/render'; +import renderers from '../../index'; +import { TableCell } from '../../shared-types'; + +afterEach(() => jest.restoreAllMocks()); + +it('reuses measured callback styles while rendering and relayouts when the callback changes', () => { + const source = { html: '
AB
' }; + const first = jest.fn((cell: TableCell) => ({ + padding: cell.x === 0 ? 8 : 4 + })); + const second = jest.fn(() => ({ padding: 12 })); + const view = (getStyleForCell: typeof first | typeof second) => ( + + ); + // Advance the profiler clock between intentional prop changes. + const now = jest.spyOn(performance, 'now'); + now.mockReturnValue(1000); + const rendered = render(view(first)); + expect(rendered.getByText('A')).toBeTruthy(); + expect(rendered.getByText('B')).toBeTruthy(); + expect(first).toHaveBeenCalledTimes(2); + now.mockReturnValue(2000); + rendered.rerender(view(first)); + expect(first).toHaveBeenCalledTimes(2); + now.mockReturnValue(3000); + rendered.rerender(view(second)); + now.mockRestore(); + expect(second).toHaveBeenCalledTimes(2); + expect(first).toHaveBeenCalledTimes(2); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts index a0c2749..a68fc7e 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts @@ -187,6 +187,7 @@ describe('table styles', () => { borderTopWidth: 0, borderRightWidth: 1, borderRightColor: 'black', + borderStyle: 'solid', borderBottomWidth: 1, borderBottomColor: 'black' }); @@ -207,7 +208,7 @@ describe('table styles', () => { }); }); - it('rules off interior rows from a border-top-only cell', () => { + it('paints the next row top border on the current cell bottom edge', () => { // The boundary below the cell is the same declaration as the one above // the next row, and it is the only half this cell can paint. expect( @@ -217,18 +218,42 @@ describe('table styles', () => { { maxX: 0, maxY: 3, - tableBorderStyle: { ...FRAMED, borderBottomWidth: 0 } + tableBorderStyle: { ...FRAMED, borderBottomWidth: 0 }, + cells: [ + { + x: 0, + y: 1, + lenX: 1, + lenY: 1, + tnode: findCell('
A
') + } + ], + getCellStyle: () => ({ borderTopWidth: 2, borderTopColor: 'red' }) } ) ).toMatchObject({ borderBottomWidth: 2, borderBottomColor: 'red' }); }); - it('rules off interior columns from a border-left-only cell', () => { + it('paints the next column left border on the current cell right edge', () => { expect( getCollapsedCellBorderStyle( { x: 1, y: 0, lenX: 1, lenY: 1 }, { borderLeftWidth: 2, borderLeftColor: 'red' }, - { maxX: 3, maxY: 0, tableBorderStyle: FRAMED } + { + maxX: 3, + maxY: 0, + tableBorderStyle: FRAMED, + cells: [ + { + x: 2, + y: 0, + lenX: 1, + lenY: 1, + tnode: findCell('
A
') + } + ], + getCellStyle: () => ({ borderLeftWidth: 2, borderLeftColor: 'red' }) + } ) ).toMatchObject({ borderRightWidth: 2, borderRightColor: 'red' }); }); @@ -259,21 +284,36 @@ describe('table styles', () => { borderBottomWidth: 1, borderBottomColor: 'blue', borderLeftWidth: 1, - borderLeftColor: 'blue' + borderLeftColor: 'blue', + borderStyle: 'solid' }); }); - it('keeps the stronger half of an interior boundary', () => { + it('uses the stronger border from the adjacent cell at a shared edge', () => { expect( getCollapsedCellBorderStyle( { x: 1, y: 1, lenX: 1, lenY: 1 }, { - borderLeftWidth: 4, - borderLeftColor: 'red', + borderLeftWidth: 9, + borderLeftColor: 'green', borderRightWidth: 1, borderRightColor: 'blue' }, - { maxX: 3, maxY: 3, tableBorderStyle: FRAMED } + { + maxX: 3, + maxY: 3, + tableBorderStyle: FRAMED, + cells: [ + { + x: 2, + y: 1, + lenX: 1, + lenY: 1, + tnode: findCell('
A
') + } + ], + getCellStyle: () => ({ borderLeftWidth: 4, borderLeftColor: 'red' }) + } ) ).toMatchObject({ borderRightWidth: 4, borderRightColor: 'red' }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts index f9f5b29..fda18d9 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts @@ -60,6 +60,32 @@ function cellStyleFor( } describe('useHtmlTableCellProps', () => { + it.each([ + ['top', 'flex-start'], + ['baseline', 'flex-start'], + ['middle', 'center'], + ['bottom', 'flex-end'] + ])('maps vertical-align:%s to justifyContent:%s', (alignment, expected) => { + expect( + cellStyleFor(`A`) + .justifyContent + ).toBe(expected); + }); + + it('keeps explicit justify-content when vertical-align is absent', () => { + expect( + cellStyleFor('A').justifyContent + ).toBe('flex-end'); + }); + + it('lets explicit vertical-align override justify-content', () => { + expect( + cellStyleFor( + 'A' + ).justifyContent + ).toBe('flex-start'); + }); + describe('default padding', () => { it('pads a bare cell by one pixel, as HTML does', () => { expect(cellStyleFor('A')).toMatchObject({ diff --git a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts index b4700f4..02ed30c 100644 --- a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts @@ -134,6 +134,25 @@ export default function computeColumnWidths( if (columnConstraints.length === 0) { return []; } + // Cell percentages use the same sizing class as col/colgroup percentages. + // Repeated rows contribute a maximum, not a sum. A colspan shares its + // preference across the columns it covers, like its intrinsic constraints. + declaredWidths = [...declaredWidths]; + for (const cell of display.cells) { + const percent = cell.constraints.percentWidth; + if (percent == null) continue; + for (let i = cell.x; i < cell.x + cell.lenX; i++) { + const declared = declaredWidths[i]; + declaredWidths[i] = { + width: null, + minWidth: 0, + maxWidth: null, + maxPercent: null, + ...declared, + percent: Math.max(declared?.percent ?? 0, percent / cell.lenX) + }; + } + } // A `max-width` may be declared in either unit, and caps the column in // whichever sizing class it ends up in. Percentage bounds travel unresolved // so that the same declarations can be reused against another table width, @@ -157,14 +176,11 @@ export default function computeColumnWidths( // Absolute column widths contribute to intrinsic minimum and preferred // widths. Percentage widths remain unresolved until distribution below, // and contribute only the absolute floor they were given. - const floor = - declared.percent === null - ? clampWidth( - declared.width ?? declared.minWidth, - declared.minWidth, - cap - ) - : declared.minWidth; + const floor = clampWidth( + declared.width ?? declared.minWidth, + declared.minWidth, + cap + ); if (floor > 0) { constraints.minWidth = Math.max(constraints.minWidth, floor); constraints.spread = Math.max(constraints.spread, floor); diff --git a/packages/heuristic-table-plugin/src/helpers/measure.ts b/packages/heuristic-table-plugin/src/helpers/measure.ts index 5ef4946..2e30812 100644 --- a/packages/heuristic-table-plugin/src/helpers/measure.ts +++ b/packages/heuristic-table-plugin/src/helpers/measure.ts @@ -1,6 +1,6 @@ -import { TNode } from '@native-html/render'; +import { I18nManager, ViewStyle } from 'react-native'; -type NativeBlockRetStyle = TNode['styles']['nativeBlockRet']; +type NativeBlockRetStyle = ViewStyle; type SpacingFields = Extract< keyof NativeBlockRetStyle, | 'borderLeftWidth' @@ -13,13 +13,6 @@ type SpacingFields = Extract< const hmarginFields: readonly SpacingFields[] = ['marginLeft', 'marginRight']; -const hinsetFields: readonly SpacingFields[] = [ - 'borderLeftWidth', - 'borderRightWidth', - 'paddingLeft', - 'paddingRight' -]; - function sumFields( style: NativeBlockRetStyle, fields: readonly SpacingFields[] @@ -45,5 +38,28 @@ export function getHorizontalMargins(style: NativeBlockRetStyle): number { * width it may pass on. */ export function getHorizontalInsets(style: NativeBlockRetStyle): number { - return sumFields(style, hinsetFields); + const rtl = + style.direction === 'rtl' || + (style.direction !== 'ltr' && I18nManager.isRTL); + const start = style.paddingInlineStart ?? style.paddingStart; + const end = style.paddingInlineEnd ?? style.paddingEnd; + const horizontal = + style.paddingInline ?? style.paddingHorizontal ?? style.padding; + const left = (rtl ? end : start) ?? style.paddingLeft ?? horizontal; + const right = (rtl ? start : end) ?? style.paddingRight ?? horizontal; + const borderStart = style.borderStartWidth; + const borderEnd = style.borderEndWidth; + return [ + left, + right, + (rtl ? borderEnd : borderStart) ?? + style.borderLeftWidth ?? + style.borderWidth, + (rtl ? borderStart : borderEnd) ?? + style.borderRightWidth ?? + style.borderWidth + ].reduce( + (total, value) => total + (typeof value === 'number' ? value : 0), + 0 + ); } diff --git a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts new file mode 100644 index 0000000..67008e0 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts @@ -0,0 +1,53 @@ +import { TNode } from '@native-html/render'; +import { ViewStyle } from 'react-native'; +import { Display } from '../shared-types'; +import { + getCollapsedCellBorderStyle, + getCollapsedTableBorderStyle, + getDefaultCellPaddingStyle +} from './tableStyles'; + +/** One saved style resolution shared by measurement and rendering. */ +export interface ResolvedCellStyle { + configStyle: ViewStyle | null; + borderStyle: ViewStyle | null; + style: ViewStyle; +} + +export default function resolveTableStyles( + display: Display, + tableStyle: ViewStyle, + collapse: boolean, + configStyles: ReadonlyMap +) { + const styles = new Map(); + for (const { tnode } of display.cells) { + const source = tnode.styles.nativeBlockRet; + const configured = configStyles.get(tnode); + styles.set(tnode, { + ...getDefaultCellPaddingStyle(source, configured), + ...source, + ...configured + }); + } + const getCellStyle = ({ tnode }: { tnode: TNode }) => styles.get(tnode)!; + const tableBorderStyle = collapse + ? getCollapsedTableBorderStyle(display, tableStyle, getCellStyle) + : null; + const cellStyles = new Map(); + for (const cell of display.cells) { + const borderStyle = collapse + ? getCollapsedCellBorderStyle(cell, getCellStyle(cell), { + ...display, + tableBorderStyle, + getCellStyle + }) + : null; + cellStyles.set(cell.tnode, { + configStyle: configStyles.get(cell.tnode) ?? null, + borderStyle, + style: { ...getCellStyle(cell), ...borderStyle } + }); + } + return { tableBorderStyle, cellStyles }; +} diff --git a/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts b/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts index 4d54d64..8e79128 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveWidth.ts @@ -1,3 +1,4 @@ +import { ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; const PERCENTAGE_REGEX = /^(\d*\.?\d+)%$/; @@ -90,6 +91,7 @@ export interface WidthConstraints { } interface ResolveWidthOptions { + style?: ViewStyle; /** * Percentage sizes do not impose an intrinsic width on a descendant whose * containing block has not been sized yet. @@ -113,9 +115,9 @@ interface ResolveWidthOptions { export function resolveWidthConstraints( tnode: TNode, containingWidth: number, - { resolvePercentages = true }: ResolveWidthOptions = {} + { resolvePercentages = true, style }: ResolveWidthOptions = {} ): WidthConstraints { - const blockStyle = tnode.styles.nativeBlockRet; + const blockStyle = style ?? tnode.styles.nativeBlockRet; const resolveCss = (value: unknown) => resolvePercentages || typeof value === 'number' ? resolveCssSize(value, containingWidth) diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index eb47515..79bad34 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -365,10 +365,9 @@ function sourceCellStyle(cell: CollapsibleCell): ViewStyle { * stronger styles, then cells over the table. * * @param matrix - See {@link CollapsibleMatrix}. - * @param tableStyle - What the table itself brings to the conflict. Passing - * the result of an earlier resolution narrows it further, which is how - * {@link HeuristicTablePluginConfig.getStyleForCell} joins in once the cell - * widths it is handed exist. + * @param tableStyle - The table source style. Each pass starts from this + * rather than a previously collapsed result, so a callback can remove a + * source cell border as well as strengthen it. * @param getCellStyle - Everything an edge cell paints with. Defaults to its * source CSS alone. */ @@ -378,8 +377,7 @@ export function getCollapsedTableBorderStyle( getCellStyle: (cell: C) => ViewStyle = sourceCellStyle ): ViewStyle { const resolvedStyle: ViewStyle = {}; - let strongestStyle: BorderCandidate['style'] = - tableStyle.borderStyle ?? 'solid'; + let strongestStyle: BorderCandidate['style'] | null = null; for (const side of ['Top', 'Right', 'Bottom', 'Left'] as const) { const winner = cellsAtOuterEdge(matrix, side).reduce( (currentWinner, cell) => @@ -394,12 +392,14 @@ export function getCollapsedTableBorderStyle( [`border${side}Color`]: winner.color }); if ( - borderStylePriority[winner.style] > borderStylePriority[strongestStyle] + winner.width > 0 && + (strongestStyle === null || + borderStylePriority[winner.style] > borderStylePriority[strongestStyle]) ) { strongestStyle = winner.style; } } - resolvedStyle.borderStyle = strongestStyle; + resolvedStyle.borderStyle = strongestStyle ?? 'solid'; return resolvedStyle; } @@ -415,6 +415,9 @@ export interface CollapsedCellEdges { maxX: number; maxY: number; tableBorderStyle: ViewStyle | null; + /** All cells and their uncollapsed styles, for shared-edge conflicts. */ + cells?: readonly CollapsibleCell[]; + getCellStyle?: (cell: CollapsibleCell) => ViewStyle; } /** @@ -431,24 +434,20 @@ export interface CollapsedCellEdges { * visible result of the collapsing model for the border styles React Native * can render, without changing the flex geometry used for row and col spans. * - * Two consequences of drawing a boundary once are worth spelling out. An - * interior boundary falls back to the opposite half of the same cell, so cells - * carrying `border-top` alone still rule off every row: under uniform cell - * styling — the case worth optimising for, since React Native cannot paint one - * side of a View in two segments anyway — both halves are the same - * declaration. And an outer boundary the wrapper resolved to nothing stays - * with the cell, so a table that declares no border of its own still shows the - * frame its edge cells ask for. - * - * `tableBorderStyle` must therefore be the edge resolved against everything - * `cellStyle` holds, `getStyleForCell` included — otherwise a border only the - * config declares loses to the weaker one the wrapper resolved from source CSS - * and is painted by neither. + * Shared boundaries compare the actual adjacent cells. Where spans bring + * several neighbours against one side, the strongest candidate paints that + * whole side; a native View cannot paint differently styled border segments. */ export function getCollapsedCellBorderStyle( cell: Pick, cellStyle: ViewStyle, - { maxX, maxY, tableBorderStyle }: CollapsedCellEdges + { + maxX, + maxY, + tableBorderStyle, + cells = [], + getCellStyle = sourceCellStyle + }: CollapsedCellEdges ): ViewStyle { const resolvedStyle: ViewStyle = {}; // A span that overruns the matrix is clipped to it rather than growing the @@ -463,17 +462,25 @@ export function getCollapsedCellBorderStyle( const width = tableBorderStyle?.[`border${side}Width`]; return typeof width === 'number' && width > 0; }; + let strongestStyle: BorderCandidate['style'] | null = null; const paint = (side: BorderSide, candidate: BorderCandidate | null) => { if (!candidate || candidate.width === 0) { Object.assign(resolvedStyle, { [`border${side}Width`]: 0 }); return; } + if ( + strongestStyle === null || + borderStylePriority[candidate.style] > borderStylePriority[strongestStyle] + ) { + strongestStyle = candidate.style; + } Object.assign(resolvedStyle, { [`border${side}Width`]: candidate.width, [`border${side}Color`]: candidate.color }); }; - const ownBorder = (side: BorderSide) => borderCandidate(cellStyle, side, true); + const ownBorder = (side: BorderSide) => + borderCandidate(cellStyle, side, true); const keepOuterBorder = (side: BorderSide) => isPaintedByTable(side) ? null : ownBorder(side); // A leading boundary is always drawn by the neighbour that precedes it, @@ -488,8 +495,26 @@ export function getCollapsedCellBorderStyle( side, isOuterEdge[side] ? keepOuterBorder(side) - : resolveBorderConflict(ownBorder(side), ownBorder(opposite)) + : cells + .filter((neighbour) => + side === 'Right' + ? neighbour.x === cell.x + cell.lenX && + neighbour.y < cell.y + cell.lenY && + neighbour.y + neighbour.lenY > cell.y + : neighbour.y === cell.y + cell.lenY && + neighbour.x < cell.x + cell.lenX && + neighbour.x + neighbour.lenX > cell.x + ) + .reduce( + (winner, neighbour) => + resolveBorderConflict( + winner, + borderCandidate(getCellStyle(neighbour), opposite, true) + ), + ownBorder(side) + ) ); } + if (strongestStyle !== null) resolvedStyle.borderStyle = strongestStyle; return resolvedStyle; } diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 866b824..63d5faa 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -55,6 +55,8 @@ export interface TColumnConstraints extends TConstraintsBase { * @public */ export interface TCellConstraints extends TConstraintsBase { + /** Preferred fraction of the table width, resolved during distribution. */ + percentWidth?: number; /** * The width at which this cell would stop benefiting from more space — the * *maximum cell width* of {@link https://www.w3.org/TR/CSS21/tables.html#auto-table-layout | CSS 2.1 §17.5.2.2}, @@ -141,6 +143,7 @@ export type TableRenderNode = | TableRoot; export interface Settings { + getStyleForCell?: HeuristicTablePluginConfig['getStyleForCell']; /** * When true, force the table to stretch to the available width. */ @@ -246,7 +249,12 @@ export interface HeuristicTablePluginConfig { /** * Customize cells appearance with this function. * - * @param cell - The cell for which styles should be provided. + * Called once per cell per layout, with provisional widths measured from + * source styles. Returned styles are saved, included in the final layout, + * and reused for rendering. Width-dependent callbacks are not iterated. + * Keep this function referentially stable to avoid unnecessary layouts. + * + * @param cell - The cell with its provisional width and constraints. */ getStyleForCell?(cell: TableCell): ViewStyle | null; } diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index fc3b383..c9b4552 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -1,6 +1,7 @@ import { ViewStyle } from 'react-native'; import { TBlock, CustomRendererProps } from '@native-html/render'; import { TableCellPropsFromParent } from './shared-types'; +import { ResolvedCellStyle } from './helpers/resolveTableStyles'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; import { CellVerticalAlign, @@ -27,6 +28,7 @@ const justifyContentForVerticalAlign: Record< }; interface InternalTableCellPropsFromParent extends TableCellPropsFromParent { + resolvedCellStyle?: ResolvedCellStyle; borderCollapse: boolean; maxX: number; maxY: number; @@ -44,9 +46,18 @@ export default function useHtmlTableCellProps({ propsFromParent, ...props }: CustomRendererProps): CustomRendererProps { - const { borderCollapse, config, cell, maxX, maxY, tableBorderStyle } = - propsFromParent as InternalTableCellPropsFromParent; - const styleFromConfig = config?.getStyleForCell?.call(null, cell); + const { + borderCollapse, + config, + cell, + maxX, + maxY, + tableBorderStyle, + resolvedCellStyle + } = propsFromParent as InternalTableCellPropsFromParent; + const styleFromConfig = resolvedCellStyle + ? resolvedCellStyle.configStyle + : config?.getStyleForCell?.call(null, cell); const verticalAlign = resolveCellVerticalAlign(props.tnode); // Vertical table-cell alignment and horizontal colspan centering are // independent, so keep both declarations in the same style contribution. @@ -64,13 +75,15 @@ export default function useHtmlTableCellProps({ // The collapsing model has to weigh every border the cell actually paints, // config included: resolving it against the source CSS alone would strip a // border that came from `getStyleForCell` and leave nothing to draw it. - const collapsedBorderStyle = borderCollapse - ? getCollapsedCellBorderStyle( - cell, - { ...props.tnode.styles.nativeBlockRet, ...styleFromConfig }, - { maxX, maxY, tableBorderStyle } - ) - : null; + const collapsedBorderStyle = resolvedCellStyle + ? resolvedCellStyle.borderStyle + : borderCollapse + ? getCollapsedCellBorderStyle( + cell, + { ...props.tnode.styles.nativeBlockRet, ...styleFromConfig }, + { maxX, maxY, tableBorderStyle } + ) + : null; // The user-agent padding is resolved against the config styles too, since a // shorthand `padding` there cannot outrank a longhand default whatever the // merge order: Yoga resolves each side against its own edge first. diff --git a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts index 4e636ef..be2e10e 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts @@ -45,6 +45,7 @@ export default function useHtmlTableProps( const baseFontCoeff = table?.baseFontCoeff; const fontWeightCoeffs = table?.fontWeightCoeffs; const borderCollapse = table?.borderCollapse; + const getStyleForCell = table?.getStyleForCell; const sharedContentWidth = useContentWidth(); const contentWidth = typeof options.overrideContentWidth === 'number' @@ -56,14 +57,16 @@ export default function useHtmlTableProps( forceStretch, baseFontCoeff, fontWeightCoeffs, - borderCollapse + borderCollapse, + getStyleForCell }), [ contentWidth, forceStretch, baseFontCoeff, fontWeightCoeffs, - borderCollapse + borderCollapse, + getStyleForCell ] ); const layout = useTableLayout({ tnode, settings }); From 7359ad55683fb0819047c78ab88b3e9cdb76e4f5 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 14:35:44 +0200 Subject: [PATCH 08/21] fix(heuristic-table-plugin): assess tests and adjust nested table rendering size --- .../src/CellContentWidthContext.ts | 10 + .../heuristic-table-plugin/src/TableLayout.ts | 9 +- .../src/TreeRenderer.tsx | 54 ++-- .../src/helpers/__tests__/HTMLTable.test.tsx | 18 +- .../TCellConstraintsComputer.test.ts | 58 ++-- .../src/helpers/__tests__/TableLayout.test.ts | 134 +++++++-- .../__tests__/computeColumnWidths.test.ts | 2 +- .../helpers/__tests__/layoutStyles.test.ts | 159 +++++++++-- .../__tests__/reduceColumnConstraints.test.ts | 2 +- .../__tests__/relaxHeightConstraint.test.ts | 2 +- .../__tests__/resolveAvailableWidth.test.ts | 49 +++- .../helpers/__tests__/tableRendering.test.tsx | 93 ++++++- .../src/helpers/__tests__/tableStyles.test.ts | 262 +++++++++++++----- .../__tests__/useHtmlTableCellProps.test.ts | 32 +-- .../src/helpers/__tests__/utils.ts | 30 ++ .../src/helpers/resolveAvailableWidth.ts | 12 +- .../src/helpers/tableStyles.ts | 46 ++- .../src/useHtmlTableProps.ts | 23 +- 18 files changed, 760 insertions(+), 235 deletions(-) create mode 100644 packages/heuristic-table-plugin/src/CellContentWidthContext.ts diff --git a/packages/heuristic-table-plugin/src/CellContentWidthContext.ts b/packages/heuristic-table-plugin/src/CellContentWidthContext.ts new file mode 100644 index 0000000..3723fd2 --- /dev/null +++ b/packages/heuristic-table-plugin/src/CellContentWidthContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; +import { TNode } from '@native-html/render'; + +/** The actual content box assigned to a rendered table cell. */ +export interface CellContentBox { + tnode: TNode; + contentWidth: number; +} + +export default createContext(undefined); diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 9aa3df6..978bd3b 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -1,4 +1,5 @@ import { sum } from 'ramda'; +import type { CellContentBox } from './CellContentWidthContext'; import { ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; import computeColumnWidths from './helpers/computeColumnWidths'; @@ -59,10 +60,14 @@ export default class TableLayout { public readonly cellStyles: ReadonlyMap; public readonly cells: TableCell[]; public readonly renderTree: TableRoot; - constructor(tnode: TNode, config: Settings) { + constructor(tnode: TNode, config: Settings, cellContentBox?: CellContentBox) { const style = tnode.styles.nativeBlockRet; this.borderCollapse = resolveBorderCollapse(tnode, config.borderCollapse); - const containingWidth = resolveAvailableWidth(tnode, config.contentWidth); + const containingWidth = resolveAvailableWidth( + tnode, + config.contentWidth, + cellContentBox + ); const availableWidth = Math.max( 0, containingWidth - getHorizontalMargins(style) diff --git a/packages/heuristic-table-plugin/src/TreeRenderer.tsx b/packages/heuristic-table-plugin/src/TreeRenderer.tsx index fd290a9..c4d9b9f 100644 --- a/packages/heuristic-table-plugin/src/TreeRenderer.tsx +++ b/packages/heuristic-table-plugin/src/TreeRenderer.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, View, ViewStyle } from 'react-native'; import { TNode, TNodeRenderer } from '@native-html/render'; import { ResolvedCellStyle } from './helpers/resolveTableStyles'; import { HeuristicTablePluginConfig, TableRenderNode } from './shared-types'; +import CellContentWidthContext from './CellContentWidthContext'; +import { getHorizontalInsets } from './helpers/measure'; const styles = StyleSheet.create({ colContainer: { flexDirection: 'column', flexGrow: 1 }, @@ -30,26 +32,42 @@ export default function TreeRenderer({ maxX: number; maxY: number; }) { + const cellContentBox = useMemo( + () => + node.type === 'cell' + ? { + tnode: node.tnode, + contentWidth: Math.max( + 0, + node.width - + getHorizontalInsets(cellStyles.get(node.tnode)!.style) + ) + } + : undefined, + [node, cellStyles] + ); if (node.type === 'cell') { return ( - + + + ); } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx b/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx index 9794d78..5195f8d 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren } from 'react'; import { render } from '@testing-library/react-native'; -import { ScrollView, View, ViewStyle } from 'react-native'; +import { ScrollView, StyleSheet, View, ViewStyle } from 'react-native'; import HTMLTable from '../../HTMLTable'; import TableLayout from '../../TableLayout'; import { HTMLTableProps } from '../../shared-types'; @@ -36,6 +36,18 @@ function renderTable(html: string, contentWidth: number) { } describe('HTMLTable containers', () => { + it('passes an explicit table height as minHeight to the wrapper', () => { + const rendered = renderTable( + '
A
', + 400 + ); + const wrapper = rendered.getByTestId('table-wrapper'); + expect(wrapper).toHaveStyle({ minHeight: 48 }); + expect(StyleSheet.flatten(wrapper.props.style)).not.toHaveProperty( + 'height' + ); + }); + it('uses the capped table width for the wrapper and overflow viewport', () => { const rendered = renderTable( '
AB
', @@ -58,7 +70,7 @@ describe('HTMLTable containers', () => { 10 ] ] as const)( - 'clamps the painted wrapper when its insets exceed its width: %s', + 'caps the wrapper style width when its insets exceed its width: %s', (html, width) => { const rendered = renderTable(html, 400); expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ width }); @@ -68,7 +80,7 @@ describe('HTMLTable containers', () => { } ); - it('paints a shrink-to-fit wrapper with its own insets', () => { + it('includes its own insets in the shrink-to-fit wrapper width', () => { const rendered = renderTable( '
A
', 400 diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index 656207d..9dba2bb 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -1,24 +1,10 @@ -import { TNode } from '@native-html/render'; import TCellConstraintsComputer, { DEFAULT_FONT_WEIGHT_COEFFS, FontWeightCoefficients } from '../TCellConstraintsComputer'; import { TCellConstraints } from '../../shared-types'; import { DEFAULT_CELL_PADDING } from '../tableStyles'; -import { createTableTNode } from './utils'; - -function findFirstCell(tnode: TNode): TNode | null { - if (tnode.tagName === 'td' || tnode.tagName === 'th') { - return tnode; - } - for (const child of tnode.children) { - const found = findFirstCell(child); - if (found) { - return found; - } - } - return null; -} +import { createCellTNode } from './utils'; /** * Pinned here so that the break-opportunity assertions below test the segment @@ -39,14 +25,13 @@ function constraintsFor( contentWidth = 400, fontWeightCoeffs?: FontWeightCoefficients ): TCellConstraints { - const table = createTableTNode(`${cellMarkup}
`); - const cell = findFirstCell(table); - expect(cell).not.toBeNull(); return new TCellConstraintsComputer({ contentWidth, baseFontCoeff: BASE_FONT_COEFF, fontWeightCoeffs - }).computeCellConstraints(cell as TNode); + }).computeCellConstraints( + createCellTNode(`${cellMarkup}
`) + ); } describe('TCellConstraintsComputer', () => { @@ -123,7 +108,7 @@ describe('TCellConstraintsComputer', () => { ); }); - it('uses the plugin heuristic to break ISO-2026 into two segments', () => { + it('should use the plugin heuristic to break ISO-2026 into two segments', () => { // This heuristic differs from default UAX #14 LB25 (HY × NU). // Both "ISO-" and "2026" have four characters. const { minWidth } = constraintsFor('ISO-2026'); @@ -348,24 +333,33 @@ describe('TCellConstraintsComputer', () => { describe('maximum cell width', () => { it.each([ - 'AAAA
BBBB', - '
AAAA
BBBB
', - 'AAAA
BBBB
' + 'AA
BBBB BBBB', + 'BBBB BBBB
AA', + '
AA
BBBB BBBB
', + '
BBBB BBBB
AA
', + 'AA
BBBB BBBB
', + 'BBBB BBBB
AA
' ])( - 'uses the widest forced line in %s without losing text density', + 'should use the widest forced line in %s without losing text density', (markup) => { const actual = constraintsFor(`${markup}`); - const line = constraintsFor('AAAA'); - expect(actual.maxWidth).toBeCloseTo(line.maxWidth); - expect(actual.contentDensity).toBeCloseTo(2 * line.contentDensity); + // The wider line contains a space: its width exceeds the longest-word + // minimum, which would otherwise mask a broken maximum calculation. + expect(actual.maxWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + 9 * 14 * BASE_FONT_COEFF + ); + expect(actual.contentDensity).toBeCloseTo(11 * 14 * BASE_FONT_COEFF); } ); - it('keeps styled fragments together when computing the widest line', () => { - const actual = constraintsFor( - 'ABCD
E' + it.each([ + 'AB CD
E', + 'E
AB CD' + ])('should sum styled fragments in the widest line of %s', (markup) => { + const actual = constraintsFor(`${markup}`); + expect(actual.maxWidth).toBeCloseTo( + DEFAULT_HORIZONTAL_PADDING + BASE_FONT_COEFF * (2 * 14 + 3 * 20) ); - expect(actual.maxWidth).toBeCloseTo(2 + 2 * BASE_FONT_COEFF * (14 + 20)); }); it('should cap the maximum width at max-width', () => { @@ -375,7 +369,7 @@ describe('TCellConstraintsComputer', () => { expect(maxWidth).toBe(100); }); - it('preserves the full unbreakable word when max-width is smaller', () => { + it('should preserve the full unbreakable word when max-width is smaller', () => { // A cap tighter than the longest word must not drive the cell below the // width it needs to hold that word. const { minWidth, maxWidth } = constraintsFor( diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts index 40524f5..420d655 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts @@ -9,7 +9,7 @@ function layoutFor(html: string, settings: Settings): TableLayout { } describe('TableLayout', () => { - it('should honour an explicit cell width end to end', () => { + it('should honour an explicit cell width through the measurement pipeline', () => { // The column was previously clamped against a maximum derived from text // alone, which ignored the declared width and collapsed this column to the // width of the word "Hi". @@ -319,6 +319,80 @@ describe('TableLayout', () => { expect(columnWidths[0]).toBeCloseTo(100); }); + it('should apply the stricter of a col and colgroup max-width', () => { + // A bound is not a declaration and does not override: the group box holds + // the column box, so both caps apply and the tighter one decides. + expect( + layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: true } + ).columnWidths[0] + ).toBeCloseTo(100); + }); + + it('should apply a colgroup max-width tighter than its col one', () => { + // The same pair the other way round, so that neither side can be the one + // silently kept. + expect( + layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: true } + ).columnWidths[0] + ).toBeCloseTo(100); + }); + + it.each([ + ['50%', '100px', 100], + ['10%', '300px', 40] + ])( + 'should apply the stricter of a %s and a %s max-width on one column', + (groupCap, columnCap, expected) => { + // The two caps arrive in different units and are only comparable once + // the table width is known, so the percentage one travels unresolved. + expect( + layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: true } + ).columnWidths[0] + ).toBeCloseTo(expected); + } + ); + + it('should read a col declared outside any colgroup', () => { + // `col` is valid as a direct child of `table`, without a wrapping group. + const { columnWidths } = layoutFor( + ` + + +
AB
`, + { contentWidth: 400, forceStretch: false } + ); + expect(columnWidths[0]).toBeCloseTo(300); + expect(columnWidths[1]).toBeCloseTo(100); + }); + + it('should share the width evenly between columns of no intrinsic width', () => { + // Every column weighs zero, so proportional distribution has nothing to + // go on. An even share keeps the surplus rather than dropping it. + const { columnWidths, totalWidth } = layoutFor( + ` + +
`, + { contentWidth: 400, forceStretch: true } + ); + expect(columnWidths).toEqual([200, 200]); + expect(totalWidth).toBeCloseTo(400); + }); + it('should keep a column holding only an image', () => { // An image contributes no text, so a text-derived maximum of zero used to // clamp this column away entirely. @@ -374,7 +448,7 @@ describe('TableLayout', () => { expect(totalWidth).toBeLessThanOrEqual(500); }); - it('passes configured font coefficients through to column measurement', () => { + it('should pass configured font coefficients through to column measurement', () => { const { columnWidths } = layoutFor( '
AAAAAAAA
', { @@ -471,27 +545,39 @@ describe('TableLayout', () => { expect(totalWidth).toBeCloseTo(300); }); - it('should not narrow a table by raising its min-width', () => { - // Laying the columns out against the floor resolves the percentage - // column against a *smaller* width, and the capped auto column cannot - // take up the slack. A floor may only widen the table. - const cols = ` - - - `; - const body = `${cols}AB`; - const { totalWidth: without } = layoutFor(`${body}
`, { - contentWidth: 600, - forceStretch: false - }); - const { totalWidth: with400 } = layoutFor( - `${body}
`, - { contentWidth: 600, forceStretch: false } - ); - expect(with400).toBeGreaterThanOrEqual(without); - }); + it.each([ + [300, [240, 11.1]], + [580, [232, 20]] + ] as const)( + 'should keep or grow the measured width when min-width is %spx and column caps are relative', + (minWidth, expectedWidths) => { + // The percentage cap falls from 240px to 120px in the 300px pass. + // Its result (120 + 20) must not replace the wider initial layout. + // At 580px the retry grows to 232 + 20, so ignoring min-width fails too. + const body = ` + + + AB`; + const settings = { + contentWidth: 600, + forceStretch: false, + baseFontCoeff: 0.65 + }; + const without = layoutFor(`${body}
`, settings); + const raised = layoutFor( + `${body}
`, + settings + ); + expect(without.columnWidths).toEqual([240, 11.1]); + raised.columnWidths.forEach((width, index) => { + expect(width).toBeCloseTo(expectedWidths[index]!); + }); + expect(raised.columnWidths).toHaveLength(2); + expect(raised.totalWidth).toBeGreaterThanOrEqual(without.totalWidth); + } + ); - it('reports the column overflow beyond the table max-width', () => { + it('should report the column overflow beyond the table max-width', () => { // The cells demand 600px inside a table that paints only 300px, so the // surplus belongs to a horizontal scroller rather than spilling out. const { totalWidth, assignableWidth } = layoutFor( @@ -505,7 +591,7 @@ describe('TableLayout', () => { expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(true); }); - it('caps usedWidth at the containing width when padding overflows', () => { + it('should cap usedWidth at the containing width when padding overflows', () => { // The insets were added back after the assignable width had been // floored at zero, so a table whose padding alone overflows its // container painted a box wider than the room it was given. @@ -519,7 +605,7 @@ describe('TableLayout', () => { expect(usedWidth).toBe(30); }); - it('caps usedWidth at max-width when padding overflows', () => { + it('should cap usedWidth at max-width when padding overflows', () => { const { usedWidth } = layoutFor( '
A
', { contentWidth: 400, forceStretch: true } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts index 52c4284..8f755e6 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts @@ -19,7 +19,7 @@ function makeDisplay( } describe('computeColumnWidths', () => { - it('preserves a fixed-width column beside a column with more content', () => { + it('should preserve a fixed-width column beside a column with more content', () => { // An icon column: a single wide glyph (`width: 40px` plus 11px of padding // and borders) whose one character makes for a very low content density. // CSS 2.1 §17.5.2.2 raises both the column minimum and maximum by the diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts index 44f68b3..b24b63f 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts @@ -6,56 +6,124 @@ import { shouldScrollTable } from '../../HTMLTable'; import { Settings } from '../../shared-types'; import { createTableTNode } from './utils'; +/** + * The settings a layout was built from, so that `renderedCellStyle` can hand + * the renderer the same config the measurement pass saw. + */ +const settingsByLayout = new WeakMap(); + function layoutFor(html: string, settings: Partial = {}) { - return new TableLayout(createTableTNode(html), { + const resolvedSettings: Settings = { contentWidth: 100, forceStretch: false, + // Pinned: the 9.1/11.1/16.1 constants below are one character of 14px text + // at this coefficient, plus the user-agent cell padding. Leaving it to the + // computer's default would break every one of them on a retune. + baseFontCoeff: 0.65, ...settings - }); + }; + const layout = new TableLayout(createTableTNode(html), resolvedSettings); + settingsByLayout.set(layout, resolvedSettings); + return layout; } -function paintedStyle(layout: TableLayout, index: number): ViewStyle { +// Inspect renderer props directly; native layout and drawing are not exercised. +function renderedCellStyle(layout: TableLayout, index: number): ViewStyle { const cell = layout.cells[index]!; return useHtmlTableCellProps({ tnode: cell.tnode, style: cell.tnode.styles.nativeBlockRet, propsFromParent: { cell, - config: layout.display, + // The renderer only falls back to `config.getStyleForCell` when the + // layout resolved no style for the cell. Passing the real settings keeps + // that fallback meaningful rather than silently yielding no style. + config: settingsByLayout.get(layout), resolvedCellStyle: layout.cellStyles.get(cell.tnode) } } as unknown as CustomRendererProps).style as ViewStyle; } -describe('layout and painted cell styles', () => { - it('paints a neighbour-only left border once on the preceding cell', () => { +describe('layout and resolved renderer styles', () => { + it.each(['ltr', 'rtl'] as const)( + 'collapses logical borders in %s cell styles', + (direction) => { + const logicalSide = direction === 'ltr' ? 'Start' : 'End'; + const layout = layoutFor('
AB
', { + borderCollapse: 'collapse', + getStyleForCell: (cell) => + cell.x === 0 + ? { borderRightWidth: 3, borderRightColor: 'blue' } + : { + direction, + [`border${logicalSide}Width`]: 5, + [`border${logicalSide}Color`]: 'red' + } + }); + expect(renderedCellStyle(layout, 0)).toMatchObject({ + borderRightWidth: 5, + borderRightColor: 'red' + }); + expect(renderedCellStyle(layout, 1).borderLeftWidth).toBe(0); + expect( + renderedCellStyle(layout, 1)[`border${logicalSide}Width`] + ).toBeUndefined(); + expect( + renderedCellStyle(layout, 1)[`border${logicalSide}Color`] + ).toBeUndefined(); + expect(layout.totalWidth).toBeCloseTo(2 * 11.1 + 5); + } + ); + + it('moves logical outer cell borders to the table wrapper', () => { + const layout = layoutFor('
A
', { + borderCollapse: 'collapse', + getStyleForCell: () => ({ + borderStartWidth: 4, + borderEndWidth: 6, + borderStartColor: 'red', + borderEndColor: 'blue' + }) + }); + expect(layout.tableBorderStyle).toMatchObject({ + borderLeftWidth: 4, + borderRightWidth: 6, + borderLeftColor: 'red', + borderRightColor: 'blue' + }); + expect(layout.horizontalInsets).toBe(10); + expect(renderedCellStyle(layout, 0).borderStartWidth).toBeUndefined(); + expect(renderedCellStyle(layout, 0).borderEndWidth).toBeUndefined(); + expect(layout.totalWidth).toBeCloseTo(11.1); + }); + it('assigns a neighbour-only left border to the preceding cell', () => { const layout = layoutFor(`
AB
`); - expect(paintedStyle(layout, 0)).toMatchObject({ + expect(renderedCellStyle(layout, 0)).toMatchObject({ borderRightWidth: 5, borderRightColor: 'red' }); - expect(paintedStyle(layout, 1)).toMatchObject({ borderLeftWidth: 0 }); + expect(renderedCellStyle(layout, 1)).toMatchObject({ borderLeftWidth: 0 }); expect(layout.columnWidths).toEqual([16.1, 11.1]); }); - it('paints a neighbour-only top border once on the preceding row', () => { + it('assigns a neighbour-only top border to the preceding row', () => { const layout = layoutFor(`
A
B
`); - expect(paintedStyle(layout, 0)).toMatchObject({ + expect(renderedCellStyle(layout, 0)).toMatchObject({ borderBottomWidth: 5, borderBottomColor: 'red' }); - expect(paintedStyle(layout, 1)).toMatchObject({ borderTopWidth: 0 }); + expect(renderedCellStyle(layout, 1)).toMatchObject({ borderTopWidth: 0 }); }); it('does not copy a cell left border onto its unrelated right edge', () => { const layout = layoutFor(`
AB
`); - expect(paintedStyle(layout, 0).borderRightWidth).toBe(0); + expect(renderedCellStyle(layout, 0).borderRightWidth).toBe(0); expect(layout.tableBorderStyle?.borderLeftWidth).toBe(5); }); @@ -65,7 +133,7 @@ describe('layout and painted cell styles', () => { C DE `); - expect(paintedStyle(layout, 0)).toMatchObject({ + expect(renderedCellStyle(layout, 0)).toMatchObject({ borderRightWidth: 5, borderRightColor: 'blue' }); @@ -76,7 +144,7 @@ describe('layout and painted cell styles', () => { A BC `); - expect(paintedStyle(layout, 0)).toMatchObject({ + expect(renderedCellStyle(layout, 0)).toMatchObject({ borderBottomWidth: 5, borderBottomColor: 'blue' }); @@ -107,13 +175,58 @@ describe('layout and painted cell styles', () => { const layout = layoutFor(`
AB
`); - expect(paintedStyle(layout, 0)).toMatchObject({ + expect(renderedCellStyle(layout, 0)).toMatchObject({ borderStyle: 'dashed', borderRightWidth: 5, borderRightColor: 'red' }); }); + // The stronger border is placed first and last in turn, so that traversal + // order cannot accidentally pick the winner. Both positions are separate + // cases rather than one loop, so a failure names the one that broke. + it.each([ + ['solid', 'dashed', true], + ['solid', 'dashed', false], + ['solid', 'dotted', true], + ['solid', 'dotted', false], + ['dashed', 'dotted', true], + ['dashed', 'dotted', false] + ] as const)( + 'prefers an equal-width %s border over a %s one, stronger declared first: %s', + (stronger, weaker, strongerFirst) => { + const first = strongerFirst ? stronger : weaker; + const second = strongerFirst ? weaker : stronger; + const firstColor = strongerFirst ? 'red' : 'blue'; + const secondColor = strongerFirst ? 'blue' : 'red'; + const interior = layoutFor(` + + +
AB
`); + expect(renderedCellStyle(interior, 0)).toMatchObject({ + borderStyle: stronger, + borderRightWidth: 2, + borderRightColor: 'red' + }); + expect(renderedCellStyle(interior, 1).borderLeftWidth).toBe(0); + const outer = + layoutFor(` + +
A
`); + expect(outer.tableBorderStyle).toMatchObject({ + borderStyle: stronger, + borderTopWidth: 2, + borderRightWidth: 2, + borderBottomWidth: 2, + borderLeftWidth: 2, + borderTopColor: 'red', + borderRightColor: 'red', + borderBottomColor: 'red', + borderLeftColor: 'red' + }); + } + ); + it('reserves collapsed outer borders only in the wrapper', () => { const layout = layoutFor( ` @@ -126,7 +239,7 @@ describe('layout and painted cell styles', () => { expect(shouldScrollTable(layout.totalWidth, layout.assignableWidth)).toBe( false ); - expect(paintedStyle(layout, 0)).toMatchObject({ + expect(renderedCellStyle(layout, 0)).toMatchObject({ borderLeftWidth: 0, borderRightWidth: 0 }); @@ -149,19 +262,19 @@ describe('layout and painted cell styles', () => { [{ padding: 8, paddingLeft: 2 }, 10], [{ padding: 8, borderWidth: 3 }, 22] ] as [ViewStyle, number][])( - 'measures callback style %j and reuses it when painting', + 'measures callback style %j and reuses it in renderer props', (style, insets) => { const getStyleForCell = jest.fn(() => style); const layout = layoutFor('
A
', { getStyleForCell }); expect(layout.totalWidth).toBeCloseTo(9.1 + insets); - expect(paintedStyle(layout, 0)).toMatchObject(style); + expect(renderedCellStyle(layout, 0)).toMatchObject(style); expect(getStyleForCell).toHaveBeenCalledTimes(1); } ); - it('lets source longhands keep precedence over callback shorthands in both passes', () => { + it('lets source longhands keep precedence over callback shorthands in measurement and renderer props', () => { const layout = layoutFor( '
A
', { @@ -169,7 +282,7 @@ describe('layout and painted cell styles', () => { } ); expect(layout.totalWidth).toBeCloseTo(9.1 + 4 + 8); - expect(paintedStyle(layout, 0)).toMatchObject({ + expect(renderedCellStyle(layout, 0)).toMatchObject({ paddingLeft: 4, padding: 8 }); @@ -185,11 +298,11 @@ describe('layout and painted cell styles', () => { ); expect(layout.horizontalInsets).toBe(5); expect(layout.totalWidth).toBeCloseTo(2 * 11.1 + 5); - expect(paintedStyle(layout, 0)).toMatchObject({ + expect(renderedCellStyle(layout, 0)).toMatchObject({ borderRightWidth: 5, borderRightColor: 'red' }); - expect(paintedStyle(layout, 1)).toMatchObject({ + expect(renderedCellStyle(layout, 1)).toMatchObject({ borderLeftWidth: 0, borderRightWidth: 0 }); @@ -221,7 +334,7 @@ describe('layout and painted cell styles', () => { }); expect(getStyleForCell.mock.calls[0]![0].width).toBeCloseTo(11.1); expect(layout.totalWidth).toBeCloseTo(29.1); - expect(paintedStyle(layout, 0).padding).toBe(10); + expect(renderedCellStyle(layout, 0).padding).toBe(10); expect(getStyleForCell).toHaveBeenCalledTimes(1); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts index ecdf123..8296625 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts @@ -1,7 +1,7 @@ import reduceColumnConstraints from '../reduceColumnConstraints'; describe('reduceColumnConstraints', () => { - it('raises a maximum below its minimum to the minimum', () => { + it('should raise a maximum below its minimum to the minimum', () => { expect( reduceColumnConstraints([ { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts index c5b2366..bd19e0c 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/relaxHeightConstraint.test.ts @@ -1,7 +1,7 @@ import relaxHeightConstraint from '../relaxHeightConstraint'; describe('relaxHeightConstraint', () => { - it('translates height to minHeight while preserving unrelated styles', () => { + it('should translate height to minHeight while preserving unrelated styles', () => { expect( relaxHeightConstraint({ height: 48, backgroundColor: 'red' }) ).toEqual({ diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts index ea4db3b..f863b61 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts @@ -111,6 +111,37 @@ describe('resolveAvailableWidth', () => { ).toBe(400); }); + it('should raise a narrow ancestor up to its min-width', () => { + expect( + availableWidthFor( + `
+
A
+
`, + 400 + ) + ).toBe(100); + }); + + // Legacy markup sizes a cell with a `width` attribute rather than CSS. It is + // the lowest-priority width hint, but it is still a width: ignoring it handed + // the nested table the whole 400px. Both spellings resolve to the same 200px + // box here, less the user-agent pixel of cell padding per side. + it.each([ + ['50%', 'as a fraction of the containing block'], + ['200', 'as an absolute length'] + ])( + 'should resolve a presentational width attribute %s (%s)', + (declaration) => { + expect( + availableWidthFor( + `
A
`, + 400, + 1 + ) + ).toBe(198); + } + ); + describe('table cell ancestors', () => { // The user-agent `td, th { padding: 1px }` never reaches `nativeBlockRet`, // so a cell which declares nothing looks bare here while the renderer @@ -159,20 +190,10 @@ describe('resolveAvailableWidth', () => { }); it('should not give the default padding to a non-cell ancestor', () => { - expect( - availableWidthFor(`
${NESTED_TABLE}
`, 400) - ).toBe(400); + // The counterpart of the cases above: the user-agent padding belongs to + // `td`/`th` alone, so a plain wrapper must hand on everything it was + // given. Widening `isTableCell` would silently shrink every nested block. + expect(availableWidthFor(`
${NESTED_TABLE}
`, 400)).toBe(400); }); }); - - it('should raise a narrow ancestor up to its min-width', () => { - expect( - availableWidthFor( - `
-
A
-
`, - 400 - ) - ).toBe(100); - }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx b/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx index 4b1d8f3..74b69dd 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx @@ -1,17 +1,75 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -import RenderHTML from '@native-html/render'; +import { StyleSheet } from 'react-native'; +import RenderHTML, { CustomBlockRenderer } from '@native-html/render'; import renderers from '../../index'; import { TableCell } from '../../shared-types'; +import HTMLTable from '../../HTMLTable'; +import TableLayout from '../../TableLayout'; +import { getHorizontalInsets } from '../measure'; +import useHtmlTableProps from '../../useHtmlTableProps'; afterEach(() => jest.restoreAllMocks()); +it.each([0, 10])( + 'sizes nested tables inside their assigned cell and %spx wrapper padding', + (padding) => { + const source = { + html: `
A
B
` + }; + const layouts = new Map(); + const TableRenderer: CustomBlockRenderer = (props) => { + const tableProps = useHtmlTableProps(props); + layouts.set(props.tnode.attributes.id!, tableProps.layout); + return ; + }; + const testRenderers = { ...renderers, table: TableRenderer }; + const getStyleForCell = () => ({ padding: 4, borderWidth: 2 }); + const view = (contentWidth: number) => ( + + ); + // RenderHTML warns in dev when its props change less than 60ms apart, and + // the deliberate rerender below is immediate. Pinning the clock and moving + // it on by a second per prop change keeps that warning out of the output. + const now = jest.spyOn(performance, 'now').mockReturnValue(1000); + const rendered = render(view(400)); + const checkWidths = () => { + const outer = layouts.get('outer'); + const inner = layouts.get('inner'); + const parent = outer!.cells[0]!; + const expected = + parent.width - + getHorizontalInsets(outer!.cellStyles.get(parent.tnode)!.style) - + 2 * padding; + expect(inner!.availableWidth).toBeCloseTo(expected); + expect(inner!.usedWidth).toBeCloseTo(expected); + return expected; + }; + const initialWidth = checkWidths(); + now.mockReturnValue(2000); + rendered.rerender(view(600)); + expect(checkWidths()).toBeGreaterThan(initialWidth); + } +); + it('reuses measured callback styles while rendering and relayouts when the callback changes', () => { - const source = { html: '
AB
' }; + const source = { + html: '
AB
' + }; + // Explicit sides override the renderer model's per-side default padding; + // a shorthand alone would leave both column widths unchanged. const first = jest.fn((cell: TableCell) => ({ - padding: cell.x === 0 ? 8 : 4 + paddingLeft: cell.x === 0 ? 8 : 4, + paddingRight: cell.x === 0 ? 8 : 4 })); - const second = jest.fn(() => ({ padding: 12 })); + const second = jest.fn(() => ({ paddingLeft: 12, paddingRight: 12 })); const view = (getStyleForCell: typeof first | typeof second) => ( ); - // Advance the profiler clock between intentional prop changes. + // Advance RenderHTML's profiler clock between the intentional prop changes + // below, which would otherwise be warned about as accidental rerenders. const now = jest.spyOn(performance, 'now'); now.mockReturnValue(1000); const rendered = render(view(first)); + const expectCellStyles = (paddings: number[], widths: number[]) => { + const cells = rendered.getAllByTestId('td'); + expect(cells).toHaveLength(2); + cells.forEach((cell, index) => { + expect(cell).toHaveStyle({ + paddingLeft: paddings[index], + paddingRight: paddings[index], + width: widths[index] + }); + // Skip composite components to inspect TreeRenderer's surrounding native + // View as well as the cell itself. Both must receive the measured width. + let wrapper = cell.parent; + while (wrapper && typeof wrapper.type !== 'string') + wrapper = wrapper.parent; + expect(wrapper).not.toBeNull(); + expect(StyleSheet.flatten(wrapper!.props.style).width).toBe( + widths[index] + ); + }); + }; + expectCellStyles([8, 4], [26, 18]); expect(rendered.getByText('A')).toBeTruthy(); expect(rendered.getByText('B')).toBeTruthy(); expect(first).toHaveBeenCalledTimes(2); now.mockReturnValue(2000); rendered.rerender(view(first)); expect(first).toHaveBeenCalledTimes(2); + expectCellStyles([8, 4], [26, 18]); now.mockReturnValue(3000); rendered.rerender(view(second)); now.mockRestore(); + expectCellStyles([12, 12], [34, 34]); expect(second).toHaveBeenCalledTimes(2); expect(first).toHaveBeenCalledTimes(2); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts index a68fc7e..adfacba 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts @@ -1,3 +1,4 @@ +import { I18nManager } from 'react-native'; import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle, @@ -6,21 +7,7 @@ import { resolveCellVerticalAlign } from '../tableStyles'; import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; -import { createTableTNode } from './utils'; - -function findCell(html: string, x = 0) { - const table = createTableTNode(html); - const cells = [] as typeof table.children; - const visit = (node: (typeof table.children)[number]) => { - if (node.tagName === 'td' || node.tagName === 'th') { - cells.push(node); - } else { - node.children.forEach(visit); - } - }; - table.children.forEach(visit); - return cells[x]; -} +import { createCellTNode, createTableTNode } from './utils'; /** A wrapper that paints all four of its resolved outer edges. */ const FRAMED = { @@ -41,14 +28,16 @@ describe('table styles', () => { describe('vertical alignment', () => { it('declares nothing when the cell inherits the HTML default', () => { expect( - resolveCellVerticalAlign(findCell('
A
')) + resolveCellVerticalAlign( + createCellTNode('
A
') + ) ).toBeNull(); }); it('honours inline cell alignment', () => { expect( resolveCellVerticalAlign( - findCell( + createCellTNode( '
A
' ) ) @@ -58,7 +47,7 @@ describe('table styles', () => { it('inherits inline row alignment', () => { expect( resolveCellVerticalAlign( - findCell( + createCellTNode( '
A
' ) ) @@ -68,10 +57,111 @@ describe('table styles', () => { it('honours the legacy valign attribute', () => { expect( resolveCellVerticalAlign( - findCell('
A
') + createCellTNode( + '
A
' + ) ) ).toBe('baseline'); }); + + it.each(['vertical-align: TOP', 'valign="TOP"'])( + 'matches the keyword in %s whatever its case', + (declaration) => { + const attribute = declaration.startsWith('valign'); + expect( + resolveCellVerticalAlign( + createCellTNode( + `
A
` + ) + ) + ).toBe('top'); + } + ); + + it('honours an important declaration without its keyword', () => { + expect( + resolveCellVerticalAlign( + createCellTNode( + '
A
' + ) + ) + ).toBe('bottom'); + }); + + it('takes the last of several declarations, as the cascade does', () => { + expect( + resolveCellVerticalAlign( + createCellTNode( + '
A
' + ) + ) + ).toBe('bottom'); + }); + + it('skips inline style segments that declare nothing', () => { + // A trailing semicolon leaves an empty segment, and a malformed one has + // no colon to split on. Neither may derail the properties around them. + expect( + resolveCellVerticalAlign( + createCellTNode( + '
A
' + ) + ) + ).toBe('bottom'); + }); + + it.each(['initial', 'unset'])( + 'resets %s to the CSS initial value', + (keyword) => { + expect( + resolveCellVerticalAlign( + createCellTNode( + `
A
` + ) + ) + ).toBe('baseline'); + } + ); + + it.each(['10px', '50%', 'super', 'text-bottom'])( + 'treats the inline-only value %s as baseline, as CSS does for a cell', + (value) => { + expect( + resolveCellVerticalAlign( + createCellTNode( + `
A
` + ) + ) + ).toBe('baseline'); + } + ); + + it.each(['inherit', 'revert', 'revert-layer'])( + 'keeps walking up the tree past %s', + (keyword) => { + // The keyword declares nothing of its own: the cell takes whatever its + // row declares, exactly as though it had said nothing at all. + expect( + resolveCellVerticalAlign( + createCellTNode( + `
A
` + ) + ) + ).toBe('top'); + } + ); + + it('reports nothing when only an inherit keyword is declared', () => { + expect( + resolveCellVerticalAlign( + createCellTNode( + '
A
' + ) + ) + ).toBeNull(); + }); }); describe('default padding', () => { @@ -85,7 +175,8 @@ describe('table styles', () => { it('gives a bare cell one pixel on every side', () => { expect( getDefaultCellPaddingStyle( - findCell('
A
').styles.nativeBlockRet + createCellTNode('
A
').styles + .nativeBlockRet ) ).toEqual(ONE_PIXEL_EVERY_SIDE); }); @@ -95,7 +186,7 @@ describe('table styles', () => { // replaces the default on that side alone — as it does in a browser. expect( getDefaultCellPaddingStyle( - findCell( + createCellTNode( '
A
' ).styles.nativeBlockRet ) @@ -105,8 +196,9 @@ describe('table styles', () => { it('declares nothing for a cell padded on all sides', () => { expect( getDefaultCellPaddingStyle( - findCell('
A
') - .styles.nativeBlockRet + createCellTNode( + '
A
' + ).styles.nativeBlockRet ) ).toEqual({}); }); @@ -114,8 +206,9 @@ describe('table styles', () => { it('keeps a zero padding at zero', () => { expect( getDefaultCellPaddingStyle( - findCell('
A
') - .styles.nativeBlockRet + createCellTNode( + '
A
' + ).styles.nativeBlockRet ) ).toEqual({}); }); @@ -145,7 +238,7 @@ describe('table styles', () => { it('lets the config decide a side the source CSS left bare', () => { expect( getDefaultCellPaddingStyle( - findCell( + createCellTNode( '
A
' ).styles.nativeBlockRet, { paddingHorizontal: 4 } @@ -174,6 +267,33 @@ describe('table styles', () => { expect(resolveBorderCollapse(table, 'separate')).toBe(false); }); + it('reads the legacy rules attribute as a collapsed table', () => { + // The HTML rendering rules give any `rules` value collapsed borders. + const table = createTableTNode( + '
A
' + ); + expect(resolveBorderCollapse(table)).toBe(true); + }); + + it.each(['collapse', 'separate'] as const)( + 'inherits %s from an ancestor that declares it', + (value) => { + // `border-collapse` is inherited, and only inline declarations survive + // the CSS processor, so the ancestors have to be walked by hand. + const table = createTableTNode( + `
A
` + ); + expect(resolveBorderCollapse(table)).toBe(value === 'collapse'); + } + ); + + it('prefers its own declaration to an inherited one', () => { + const table = createTableTNode( + '
A
' + ); + expect(resolveBorderCollapse(table)).toBe(false); + }); + it('removes duplicate leading and top cell edges', () => { // An interior cell keeps only the trailing and bottom halves it owns. expect( @@ -208,56 +328,6 @@ describe('table styles', () => { }); }); - it('paints the next row top border on the current cell bottom edge', () => { - // The boundary below the cell is the same declaration as the one above - // the next row, and it is the only half this cell can paint. - expect( - getCollapsedCellBorderStyle( - { x: 0, y: 0, lenX: 1, lenY: 1 }, - { borderTopWidth: 2, borderTopColor: 'red' }, - { - maxX: 0, - maxY: 3, - tableBorderStyle: { ...FRAMED, borderBottomWidth: 0 }, - cells: [ - { - x: 0, - y: 1, - lenX: 1, - lenY: 1, - tnode: findCell('
A
') - } - ], - getCellStyle: () => ({ borderTopWidth: 2, borderTopColor: 'red' }) - } - ) - ).toMatchObject({ borderBottomWidth: 2, borderBottomColor: 'red' }); - }); - - it('paints the next column left border on the current cell right edge', () => { - expect( - getCollapsedCellBorderStyle( - { x: 1, y: 0, lenX: 1, lenY: 1 }, - { borderLeftWidth: 2, borderLeftColor: 'red' }, - { - maxX: 3, - maxY: 0, - tableBorderStyle: FRAMED, - cells: [ - { - x: 2, - y: 0, - lenX: 1, - lenY: 1, - tnode: findCell('
A
') - } - ], - getCellStyle: () => ({ borderLeftWidth: 2, borderLeftColor: 'red' }) - } - ) - ).toMatchObject({ borderRightWidth: 2, borderRightColor: 'red' }); - }); - it('keeps an outside edge the table wrapper does not paint', () => { // A border a cell only gets from `getStyleForCell` is invisible to the // wrapper resolution, so stripping it here would lose the frame. @@ -309,7 +379,7 @@ describe('table styles', () => { y: 1, lenX: 1, lenY: 1, - tnode: findCell('
A
') + tnode: createCellTNode('
A
') } ], getCellStyle: () => ({ borderLeftWidth: 4, borderLeftColor: 'red' }) @@ -413,4 +483,42 @@ describe('table styles', () => { }); }); }); + + describe('writing direction', () => { + // A style which declares no `direction` of its own follows the locale, so + // the side a logical edge lands on is only knowable from `I18nManager`. + // This is the branch a right-to-left app takes, and the explicit + // `direction` one covered elsewhere never reaches it. + afterEach(() => jest.restoreAllMocks()); + + const LOGICAL_START = { borderStartWidth: 7, borderStartColor: 'red' }; + + it.each([ + [false, 'borderLeftWidth'], + [true, 'borderRightWidth'] + ] as const)( + 'resolves a logical start edge with isRTL %s', + (isRTL, physicalSide) => { + jest.replaceProperty(I18nManager, 'isRTL', isRTL); + expect( + getCollapsedCellBorderStyle( + { x: 0, y: 0, lenX: 1, lenY: 1 }, + LOGICAL_START, + { maxX: 0, maxY: 0, tableBorderStyle: null } + ) + ).toMatchObject({ [physicalSide]: 7 }); + } + ); + + it('lets an explicit direction outrank the locale', () => { + jest.replaceProperty(I18nManager, 'isRTL', true); + expect( + getCollapsedCellBorderStyle( + { x: 0, y: 0, lenX: 1, lenY: 1 }, + { ...LOGICAL_START, direction: 'ltr' }, + { maxX: 0, maxY: 0, tableBorderStyle: null } + ) + ).toMatchObject({ borderLeftWidth: 7, borderRightWidth: 0 }); + }); + }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts index fda18d9..e1f2219 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts @@ -1,21 +1,8 @@ import { ViewStyle } from 'react-native'; -import { CustomRendererProps, TBlock, TNode } from '@native-html/render'; +import { CustomRendererProps, TBlock } from '@native-html/render'; import useHtmlTableCellProps from '../../useHtmlTableCellProps'; import { HeuristicTablePluginConfig, TableCell } from '../../shared-types'; -import { createTableTNode } from './utils'; - -function findFirstCell(tnode: TNode): TNode | null { - if (tnode.tagName === 'td' || tnode.tagName === 'th') { - return tnode; - } - for (const child of tnode.children) { - const cell = findFirstCell(child); - if (cell) { - return cell; - } - } - return null; -} +import { createCellTNode } from './utils'; /** * The style the cell renderer hands to the default renderer for the first cell @@ -30,13 +17,10 @@ function cellStyleFor( cellMarkup: string, config: HeuristicTablePluginConfig = {} ): ViewStyle { - const tnode = findFirstCell( - createTableTNode(`${cellMarkup}
`) - ); - expect(tnode).not.toBeNull(); + const tnode = createCellTNode(`${cellMarkup}
`); const cell: TableCell = { type: 'cell', - tnode: tnode as TNode, + tnode, x: 0, y: 0, lenX: 1, @@ -46,7 +30,7 @@ function cellStyleFor( }; const props = { tnode, - style: tnode?.styles.nativeBlockRet, + style: tnode.styles.nativeBlockRet, propsFromParent: { cell, config, @@ -60,6 +44,12 @@ function cellStyleFor( } describe('useHtmlTableCellProps', () => { + it.each(['td', 'th'])('passes an explicit %s height as minHeight', (tag) => { + const style = cellStyleFor(`<${tag} style="height:48px">A`); + expect(style.minHeight).toBe(48); + expect(style).not.toHaveProperty('height'); + }); + it.each([ ['top', 'flex-start'], ['baseline', 'flex-start'], diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts index 8c3edb6..c537400 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts @@ -34,3 +34,33 @@ export function createTableTNode(html: string, nth = 0) { expect(table?.tagName).toBe('table'); return table as TNode; } + +/** + * Every `td`/`th` of `tnode`, in document order. + * + * @remarks + * A cell is not descended into, so a table nested inside one contributes none + * of its own cells to the result. + */ +function collectCells(tnode: TNode, found: TNode[] = []): TNode[] { + if (tnode.tagName === 'td' || tnode.tagName === 'th') { + found.push(tnode); + } else { + for (const child of tnode.children) { + collectCells(child, found); + } + } + return found; +} + +/** + * Build a transient render tree from `html` and return one of the cells of its + * outermost table. + * + * @param nth - Which cell to return, in document order. Defaults to the first. + */ +export function createCellTNode(html: string, nth = 0): TNode { + const cell = collectCells(createTableTNode(html))[nth]; + expect(cell?.tagName).toMatch(/^t[dh]$/); + return cell as TNode; +} diff --git a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts index 707518f..02c05f2 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts @@ -2,6 +2,7 @@ import { TNode } from '@native-html/render'; import { getHorizontalInsets, getHorizontalMargins } from './measure'; import { clampWidth, resolveWidthConstraints } from './resolveWidth'; import { getPaintedBlockStyle } from './tableStyles'; +import type { CellContentBox } from '../CellContentWidthContext'; /** * The width `tnode` offers to a block-level child, i.e. its content box. @@ -45,10 +46,19 @@ function reduceToContentBox(tnode: TNode, containingWidth: number): number { */ export default function resolveAvailableWidth( tnode: TNode, - contentWidth: number + contentWidth: number, + cellContentBox?: CellContentBox ): number { const ancestors: TNode[] = []; for (let parent = tnode.parent; parent; parent = parent.parent) { + if (parent === cellContentBox?.tnode) { + // Start inside the assigned cell, then account only for wrappers + // between that cell and this table. Its insets are already deducted. + return ancestors.reduce( + (width, ancestor) => reduceToContentBox(ancestor, width), + cellContentBox.contentWidth + ); + } ancestors.unshift(parent); } return ancestors.reduce( diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index 79bad34..881d754 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -1,4 +1,4 @@ -import { ViewStyle } from 'react-native'; +import { I18nManager, ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; import { Display, DisplayCell, TableCell } from '../shared-types'; @@ -281,14 +281,32 @@ function borderCandidate( side: BorderSide, fromCell: boolean ): BorderCandidate { + const rtl = + style.direction === 'rtl' || + (style.direction !== 'ltr' && I18nManager.isRTL); + const logicalSide = + side === 'Left' + ? rtl + ? 'End' + : 'Start' + : side === 'Right' + ? rtl + ? 'Start' + : 'End' + : null; // The CSS processor always expands `border` per side, but // `getStyleForCell` is hand-written and the shorthand is the natural way to // reach for a border there, so fall back to it. An explicit per-side `0` // still wins, as it does in React Native. - const width = (style[`border${side}Width`] ?? style.borderWidth) as - | number - | undefined; - const color = (style[`border${side}Color`] ?? + const width = ((logicalSide + ? style[`border${logicalSide}Width`] + : undefined) ?? + style[`border${side}Width`] ?? + style.borderWidth) as number | undefined; + const color = ((logicalSide + ? style[`border${logicalSide}Color`] + : undefined) ?? + style[`border${side}Color`] ?? style.borderColor) as ViewStyle['borderColor']; return { color: color ?? 'black', @@ -298,6 +316,20 @@ function borderCandidate( }; } +/** Prevent logical edges from overriding the resolved physical borders. */ +function clearLogicalBorders(style: ViewStyle): ViewStyle { + const cleared: ViewStyle = {}; + for (const key of [ + 'borderStartWidth', + 'borderEndWidth', + 'borderStartColor', + 'borderEndColor' + ] as const) { + if (style[key] != null) Object.assign(cleared, { [key]: undefined }); + } + return cleared; +} + function resolveBorderConflict( winner: BorderCandidate, candidate: BorderCandidate @@ -376,7 +408,7 @@ export function getCollapsedTableBorderStyle( tableStyle: ViewStyle, getCellStyle: (cell: C) => ViewStyle = sourceCellStyle ): ViewStyle { - const resolvedStyle: ViewStyle = {}; + const resolvedStyle: ViewStyle = clearLogicalBorders(tableStyle); let strongestStyle: BorderCandidate['style'] | null = null; for (const side of ['Top', 'Right', 'Bottom', 'Left'] as const) { const winner = cellsAtOuterEdge(matrix, side).reduce( @@ -449,7 +481,7 @@ export function getCollapsedCellBorderStyle( getCellStyle = sourceCellStyle }: CollapsedCellEdges ): ViewStyle { - const resolvedStyle: ViewStyle = {}; + const resolvedStyle: ViewStyle = clearLogicalBorders(cellStyle); // A span that overruns the matrix is clipped to it rather than growing the // table, so it sits at the edge it overran. const isOuterEdge: Record = { diff --git a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts index be2e10e..8633eb9 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts @@ -1,4 +1,7 @@ -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; +import CellContentWidthContext, { + CellContentBox +} from './CellContentWidthContext'; import { CustomRendererProps, TBlock, @@ -11,14 +14,16 @@ import TableLayout from './TableLayout'; function useTableLayout({ tnode, - settings + settings, + cellContentBox }: { tnode: TNode; settings: Settings; + cellContentBox?: CellContentBox; }) { return useMemo(() => { - return new TableLayout(tnode, settings); - }, [tnode, settings]); + return new TableLayout(tnode, settings, cellContentBox); + }, [tnode, settings, cellContentBox]); } /** @@ -47,6 +52,7 @@ export default function useHtmlTableProps( const borderCollapse = table?.borderCollapse; const getStyleForCell = table?.getStyleForCell; const sharedContentWidth = useContentWidth(); + const cellContentBox = useContext(CellContentWidthContext); const contentWidth = typeof options.overrideContentWidth === 'number' ? options.overrideContentWidth @@ -69,7 +75,14 @@ export default function useHtmlTableProps( getStyleForCell ] ); - const layout = useTableLayout({ tnode, settings }); + const layout = useTableLayout({ + tnode, + settings, + cellContentBox: + typeof options.overrideContentWidth === 'number' + ? undefined + : cellContentBox + }); return { layout, settings, From 7247c6a579815855a7f388ed311d3b0fa3d660f6 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 15:26:43 +0200 Subject: [PATCH 09/21] feat(heuristic-table-plugin): hide height growts behind a prop --- packages/heuristic-table-plugin/README.md | 19 ++ .../etc/heuristic-table-plugin.api.md | 283 +++++++++--------- .../heuristic-table-plugin/src/HTMLTable.tsx | 9 +- .../src/helpers/__tests__/HTMLTable.test.tsx | 28 +- .../__tests__/useHtmlTableCellProps.test.ts | 17 +- .../src/shared-types.ts | 17 ++ .../src/useHtmlTableCellProps.ts | 9 +- 7 files changed, 228 insertions(+), 154 deletions(-) diff --git a/packages/heuristic-table-plugin/README.md b/packages/heuristic-table-plugin/README.md index 59fdcc1..d8f4c60 100644 --- a/packages/heuristic-table-plugin/README.md +++ b/packages/heuristic-table-plugin/README.md @@ -265,3 +265,22 @@ block leaves it. Set it to `false` in `renderersProps.table` to let an auto-width table shrink to fit its content instead. A table with an explicit width always distributes that width over its columns, whatever `forceStretch` is set to. + +### Table and cell heights + +Per [CSS 2.1 §17.5.3](https://www.w3.org/TR/CSS21/tables.html#height-layout), +`height` on a `table`, `tr`, `th` or `td` box is only a *minimum*: the box +always grows to fit its content. React Native has no table layout algorithm to +shrink a row back down, so this plugin enforces a declared `height` as written +by default, and taller content overflows it. + +Set `growBeyondHeight` to `true` in `renderersProps.table` to get the CSS +behavior instead: an explicit `height` on the table or on any of its cells is +folded into `minHeight`, and the box grows past it to fit its content. + +```tsx + +``` diff --git a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md index f132b1c..92579f0 100644 --- a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md +++ b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md @@ -1,141 +1,142 @@ -## API Report File for "@native-html/heuristic-table-plugin" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { CustomBlockRenderer } from '@native-html/render'; -import { CustomRendererProps } from '@native-html/render'; -import { HTMLContentModel } from '@native-html/render'; -import { HTMLElementModel } from '@native-html/render'; -import { PropsFromParent } from '@native-html/render'; -import { default as React_2 } from 'react'; -import { TBlock } from '@native-html/render'; -import { TNode } from '@native-html/render'; -import { ViewStyle } from 'react-native'; - -// @public (undocumented) -export interface CellProperties extends Coordinates { - // Warning: (ae-forgotten-export) The symbol "TCellConstraints" needs to be exported by the entry point index.d.ts - // - // (undocumented) - constraints: TCellConstraints; - // (undocumented) - lenX: number; - // (undocumented) - lenY: number; -} - -// @public -export const colgroupModel: HTMLElementModel<'colgroup', HTMLContentModel.block>; - -// @public (undocumented) -export interface Coordinates { - // (undocumented) - x: number; - // (undocumented) - y: number; -} - -// @public -export const DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients; - -// @public (undocumented) -export interface DisplayCell extends CellProperties { - // (undocumented) - tnode: TNode; -} - -// @public -export type FontWeightCoefficients = Record; - -// @public -export interface HeuristicTablePluginConfig { - baseFontCoeff?: number; - borderCollapse?: 'collapse' | 'separate'; - fontWeightCoeffs?: FontWeightCoefficients; - forceStretch?: boolean; - getStyleForCell?(cell: TableCell): ViewStyle | null; -} - -// @public -export const HTMLTable: React_2.NamedExoticComponent; - -// @public -export interface HTMLTableProps extends CustomRendererProps { - // (undocumented) - config: HeuristicTablePluginConfig; - // Warning: (ae-forgotten-export) The symbol "TableLayout" needs to be exported by the entry point index.d.ts - // - // (undocumented) - layout: TableLayout; - // Warning: (ae-forgotten-export) The symbol "Settings" needs to be exported by the entry point index.d.ts - // - // (undocumented) - settings: Settings; -} - -// @public -const renderers: Record<'th' | 'td' | 'table', CustomBlockRenderer>; -export default renderers; - -// @public -export interface TableCell extends DisplayCell { - // (undocumented) - type: 'cell'; - // (undocumented) - width: number; -} - -// @public -export interface TableCellPropsFromParent extends PropsFromParent { - // (undocumented) - cell: TableCell; - // (undocumented) - config?: HeuristicTablePluginConfig; -} - -// @public -export interface TableFlexColumnContainer { - // (undocumented) - children: (TableFlexRowContainer | TableCell)[]; - // (undocumented) - type: 'col-container'; -} - -// @public -export interface TableFlexRowContainer { - // (undocumented) - children: (TableFlexColumnContainer | TableCell)[]; - // (undocumented) - type: 'row-container'; -} - -// @public -export const TableRenderer: CustomBlockRenderer; - -// @public (undocumented) -export interface TableRoot { - // (undocumented) - children: TableFlexRowContainer[]; - // (undocumented) - type: 'root'; -} - -// @public -export const TdRenderer: CustomBlockRenderer; - -// @public -export const ThRenderer: CustomBlockRenderer; - -// @public -export function useHtmlTableCellProps(input: CustomRendererProps): CustomRendererProps; - -// @public -export function useHtmlTableProps(input: CustomRendererProps, options?: { - overrideContentWidth?: number; -}): HTMLTableProps; - -// (No @packageDocumentation comment for this package) - -``` +## API Report File for "@native-html/heuristic-table-plugin" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { CustomBlockRenderer } from '@native-html/render'; +import { CustomRendererProps } from '@native-html/render'; +import { HTMLContentModel } from '@native-html/render'; +import { HTMLElementModel } from '@native-html/render'; +import { PropsFromParent } from '@native-html/render'; +import { default as React_2 } from 'react'; +import { TBlock } from '@native-html/render'; +import { TNode } from '@native-html/render'; +import { ViewStyle } from 'react-native'; + +// @public (undocumented) +export interface CellProperties extends Coordinates { + // Warning: (ae-forgotten-export) The symbol "TCellConstraints" needs to be exported by the entry point index.d.ts + // + // (undocumented) + constraints: TCellConstraints; + // (undocumented) + lenX: number; + // (undocumented) + lenY: number; +} + +// @public +export const colgroupModel: HTMLElementModel<'colgroup', HTMLContentModel.block>; + +// @public (undocumented) +export interface Coordinates { + // (undocumented) + x: number; + // (undocumented) + y: number; +} + +// @public +export const DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients; + +// @public (undocumented) +export interface DisplayCell extends CellProperties { + // (undocumented) + tnode: TNode; +} + +// @public +export type FontWeightCoefficients = Record; + +// @public +export interface HeuristicTablePluginConfig { + baseFontCoeff?: number; + borderCollapse?: 'collapse' | 'separate'; + fontWeightCoeffs?: FontWeightCoefficients; + forceStretch?: boolean; + getStyleForCell?(cell: TableCell): ViewStyle | null; + growBeyondHeight?: boolean; +} + +// @public +export const HTMLTable: React_2.NamedExoticComponent; + +// @public +export interface HTMLTableProps extends CustomRendererProps { + // (undocumented) + config: HeuristicTablePluginConfig; + // Warning: (ae-forgotten-export) The symbol "TableLayout" needs to be exported by the entry point index.d.ts + // + // (undocumented) + layout: TableLayout; + // Warning: (ae-forgotten-export) The symbol "Settings" needs to be exported by the entry point index.d.ts + // + // (undocumented) + settings: Settings; +} + +// @public +const renderers: Record<'th' | 'td' | 'table', CustomBlockRenderer>; +export default renderers; + +// @public +export interface TableCell extends DisplayCell { + // (undocumented) + type: 'cell'; + // (undocumented) + width: number; +} + +// @public +export interface TableCellPropsFromParent extends PropsFromParent { + // (undocumented) + cell: TableCell; + // (undocumented) + config?: HeuristicTablePluginConfig; +} + +// @public +export interface TableFlexColumnContainer { + // (undocumented) + children: (TableFlexRowContainer | TableCell)[]; + // (undocumented) + type: 'col-container'; +} + +// @public +export interface TableFlexRowContainer { + // (undocumented) + children: (TableFlexColumnContainer | TableCell)[]; + // (undocumented) + type: 'row-container'; +} + +// @public +export const TableRenderer: CustomBlockRenderer; + +// @public (undocumented) +export interface TableRoot { + // (undocumented) + children: TableFlexRowContainer[]; + // (undocumented) + type: 'root'; +} + +// @public +export const TdRenderer: CustomBlockRenderer; + +// @public +export const ThRenderer: CustomBlockRenderer; + +// @public +export function useHtmlTableCellProps(input: CustomRendererProps): CustomRendererProps; + +// @public +export function useHtmlTableProps(input: CustomRendererProps, options?: { + overrideContentWidth?: number; +}): HTMLTableProps; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index 10e6c4b..4a831f9 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -61,9 +61,12 @@ const HTMLTable = memo(function HTMLTable({ = {} +) { const tnode = createTableTNode(html); const settings = { contentWidth, forceStretch: false }; const layout = new TableLayout(tnode, settings); @@ -28,7 +35,7 @@ function renderTable(html: string, contentWidth: number) { tnode, layout, settings, - config: settings, + config: { ...settings, ...config }, style: tnode.styles.nativeBlockRet, TDefaultRenderer: DefaultRenderer } as unknown as HTMLTableProps; @@ -36,12 +43,25 @@ function renderTable(html: string, contentWidth: number) { } describe('HTMLTable containers', () => { - it('passes an explicit table height as minHeight to the wrapper', () => { + it('enforces an explicit table height on the wrapper by default', () => { const rendered = renderTable( '
A
', 400 ); const wrapper = rendered.getByTestId('table-wrapper'); + expect(wrapper).toHaveStyle({ height: 48 }); + expect(StyleSheet.flatten(wrapper.props.style)).not.toHaveProperty( + 'minHeight' + ); + }); + + it('passes an explicit table height as minHeight when growBeyondHeight is set', () => { + const rendered = renderTable( + '
A
', + 400, + { growBeyondHeight: true } + ); + const wrapper = rendered.getByTestId('table-wrapper'); expect(wrapper).toHaveStyle({ minHeight: 48 }); expect(StyleSheet.flatten(wrapper.props.style)).not.toHaveProperty( 'height' diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts index e1f2219..27858f6 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts @@ -44,12 +44,23 @@ function cellStyleFor( } describe('useHtmlTableCellProps', () => { - it.each(['td', 'th'])('passes an explicit %s height as minHeight', (tag) => { + it.each(['td', 'th'])('enforces an explicit %s height by default', (tag) => { const style = cellStyleFor(`<${tag} style="height:48px">A`); - expect(style.minHeight).toBe(48); - expect(style).not.toHaveProperty('height'); + expect(style.height).toBe(48); + expect(style).not.toHaveProperty('minHeight'); }); + it.each(['td', 'th'])( + 'passes an explicit %s height as minHeight when growBeyondHeight is set', + (tag) => { + const style = cellStyleFor(`<${tag} style="height:48px">A`, { + growBeyondHeight: true + }); + expect(style.minHeight).toBe(48); + expect(style).not.toHaveProperty('height'); + } + ); + it.each([ ['top', 'flex-start'], ['baseline', 'flex-start'], diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 63d5faa..e507373 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -246,6 +246,23 @@ export interface HeuristicTablePluginConfig { * @defaultValue `separate` */ borderCollapse?: 'collapse' | 'separate'; + /** + * When true, an explicit `height` on the table, or on any of its cells, is + * treated as a minimum: the box still grows to fit content taller than it. + * When false, that `height` is enforced as written and taller content + * overflows it. + * + * @remarks + * Per {@link https://www.w3.org/TR/CSS21/tables.html#height-layout | CSS 2.1 + * §17.5.3}, `height` on a `table`, `tr`, `th` or `td` box is only a minimum, + * so `true` is the faithful reading of the HTML. It is off by default + * because React Native has no table layout algorithm to shrink a row back + * down, and a document whose markup sizes its tables is better served by a + * box that stays the size it asked for. + * + * @defaultValue false + */ + growBeyondHeight?: boolean; /** * Customize cells appearance with this function. * diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index c9b4552..cbd9046 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -95,9 +95,12 @@ export default function useHtmlTableCellProps({ // The user-agent stylesheet is the weakest declaration of the three, and // only covers the sides no author declaration reached. ...defaultPaddingStyle, - // An explicit height on a cell is a minimum height in HTML, so that the - // cell still grows to fit its content. - ...relaxHeightConstraint(props.style), + // An explicit height on a cell is a minimum height in HTML, but only + // `growBeyondHeight` opts into letting the cell grow past it; by default + // the declared height is enforced as written. + ...(config?.growBeyondHeight + ? relaxHeightConstraint(props.style) + : props.style), flexGrow: 1, flexShrink: 0, ...alignmentStyles, From b09711de63eea3f5a6dd4c38874f7491f51d81be Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 15:56:40 +0200 Subject: [PATCH 10/21] fix(heuristic-table-plugin): horizontal and vertical paddings adjustements --- packages/heuristic-table-plugin/README.md | 5 ++ .../helpers/__tests__/layoutStyles.test.ts | 6 +- .../helpers/__tests__/tableRendering.test.tsx | 55 ++++++++++++++++++- .../__tests__/useHtmlTableCellProps.test.ts | 14 ++--- .../src/helpers/__tests__/utils.ts | 3 + .../src/helpers/resolveTableStyles.ts | 7 ++- .../src/helpers/tableStyles.ts | 18 ++++++ .../src/useHtmlTableCellProps.ts | 3 +- 8 files changed, 94 insertions(+), 17 deletions(-) diff --git a/packages/heuristic-table-plugin/README.md b/packages/heuristic-table-plugin/README.md index d8f4c60..98f0eb4 100644 --- a/packages/heuristic-table-plugin/README.md +++ b/packages/heuristic-table-plugin/README.md @@ -111,6 +111,11 @@ cell with `padding-left: 8px` keeps the default pixel on the three sides it left alone, and `padding: 0` removes it altogether. A padding from `getStyleForCell`, shorthand included, replaces it too. +Callback padding overrides the source padding on the sides it covers, including +resolved user-agent styles and inline CSS. For example, `{ padding: 8 }` sets +every side to 8 even if the cell declares `padding-left: 4px`; a callback's own +`paddingLeft` still takes precedence over its `padding` shorthand. + `getStyleForCell` padding and borders participate in layout. The plugin first calculates provisional cell widths from source styles, calls the callback once per cell, then calculates final widths using its returned styles. Those same diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts index b24b63f..94e32fb 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts @@ -274,16 +274,16 @@ describe('layout and resolved renderer styles', () => { } ); - it('lets source longhands keep precedence over callback shorthands in measurement and renderer props', () => { + it('lets callback shorthands override source longhands in measurement and renderer props', () => { const layout = layoutFor( '
A
', { getStyleForCell: () => ({ padding: 8 }) } ); - expect(layout.totalWidth).toBeCloseTo(9.1 + 4 + 8); + expect(layout.totalWidth).toBeCloseTo(9.1 + 8 + 8); expect(renderedCellStyle(layout, 0)).toMatchObject({ - paddingLeft: 4, + paddingLeft: 8, padding: 8 }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx b/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx index 74b69dd..6ed4d70 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, ViewStyle } from 'react-native'; import RenderHTML, { CustomBlockRenderer } from '@native-html/render'; import renderers from '../../index'; import { TableCell } from '../../shared-types'; @@ -11,6 +11,57 @@ import useHtmlTableProps from '../../useHtmlTableProps'; afterEach(() => jest.restoreAllMocks()); +describe.each([true, false])( + 'callback padding with user-agent styles %s', + (enableUserAgentStyles) => { + it.each([ + [{ padding: 8 }, '', 8, 8], + [{ paddingHorizontal: 8 }, '', 8, 8], + [{ padding: 0 }, '', 0, 0], + [{ padding: 8, paddingLeft: 3 }, '', 3, 8], + [{ padding: 8 }, 'padding-left:4px', 8, 8], + [{ paddingVertical: 8 }, 'padding-left:3px;padding-right:5px', 3, 5] + ] as [ViewStyle, string, number, number][])( + 'measures and renders %j over source %s', + (padding, sourceStyle, left, right) => { + const getStyleForCell = jest.fn(() => padding); + const rendered = render( + A` + }} + renderers={renderers} + renderersProps={{ + table: { + forceStretch: false, + baseFontCoeff: 0.5, + getStyleForCell + } + }} + /> + ); + const cell = rendered.getByTestId('td'); + const width = 10 + left + right; + expect(cell).toHaveStyle({ + paddingLeft: left, + paddingRight: right, + width + }); + if (padding.paddingVertical != null) { + expect(cell).toHaveStyle({ paddingTop: 8, paddingBottom: 8 }); + } + let wrapper = cell.parent; + while (wrapper && typeof wrapper.type !== 'string') + wrapper = wrapper.parent; + expect(StyleSheet.flatten(wrapper!.props.style).width).toBe(width); + expect(getStyleForCell).toHaveBeenCalledTimes(1); + } + ); + } +); + it.each([0, 10])( 'sizes nested tables inside their assigned cell and %spx wrapper padding', (padding) => { @@ -63,8 +114,6 @@ it('reuses measured callback styles while rendering and relayouts when the callb const source = { html: '
AB
' }; - // Explicit sides override the renderer model's per-side default padding; - // a shorthand alone would leave both column widths unchanged. const first = jest.fn((cell: TableCell) => ({ paddingLeft: cell.x === 0 ? 8 : 4, paddingRight: cell.x === 0 ? 8 : 4 diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts index 27858f6..53adf31 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts @@ -122,13 +122,13 @@ describe('useHtmlTableCellProps', () => { getStyleForCell: () => ({ padding: 8 }) }); - // No longhand may be emitted beside the config shorthand: Yoga resolves - // a side against its own edge first, so a default of 1 would win. - expect(style.padding).toBe(8); - expect(style).not.toHaveProperty('paddingTop'); - expect(style).not.toHaveProperty('paddingRight'); - expect(style).not.toHaveProperty('paddingBottom'); - expect(style).not.toHaveProperty('paddingLeft'); + expect(style).toMatchObject({ + padding: 8, + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8 + }); }); it('leaves a cell asking for no padding unpadded', () => { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts index c537400..645bc3d 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts @@ -3,6 +3,9 @@ import { TNode } from '@native-html/render'; import colgroupModel from '../../ColgroupModel'; const engine = new TRenderEngine({ + // Unit fixtures isolate plugin defaults. Public-renderer tests retain the + // RenderHTML defaults, including user-agent styles. + stylesConfig: { enableUserAgentStyles: false }, customizeHTMLModels(defaultModels) { return { ...defaultModels, diff --git a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts index 67008e0..b061ec4 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts @@ -4,7 +4,8 @@ import { Display } from '../shared-types'; import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle, - getDefaultCellPaddingStyle + getDefaultCellPaddingStyle, + resolveConfiguredCellStyle } from './tableStyles'; /** One saved style resolution shared by measurement and rendering. */ @@ -23,7 +24,7 @@ export default function resolveTableStyles( const styles = new Map(); for (const { tnode } of display.cells) { const source = tnode.styles.nativeBlockRet; - const configured = configStyles.get(tnode); + const configured = resolveConfiguredCellStyle(configStyles.get(tnode)); styles.set(tnode, { ...getDefaultCellPaddingStyle(source, configured), ...source, @@ -44,7 +45,7 @@ export default function resolveTableStyles( }) : null; cellStyles.set(cell.tnode, { - configStyle: configStyles.get(cell.tnode) ?? null, + configStyle: resolveConfiguredCellStyle(configStyles.get(cell.tnode)), borderStyle, style: { ...getCellStyle(cell), ...borderStyle } }); diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index 881d754..889dfe6 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -191,6 +191,24 @@ export function getDefaultCellPaddingStyle( return resolvedStyle; } +/** Expand callback shorthands so resolved source longhands cannot mask them. */ +export function resolveConfiguredCellStyle( + style: ViewStyle | null | undefined +): ViewStyle | null { + if (!style) return null; + const horizontal = style.paddingHorizontal ?? style.padding; + const vertical = style.paddingVertical ?? style.padding; + return { + ...(horizontal != null + ? { paddingLeft: horizontal, paddingRight: horizontal } + : null), + ...(vertical != null + ? { paddingTop: vertical, paddingBottom: vertical } + : null), + ...style + }; +} + /** * Resolve the vertical alignment a native table cell should emulate. * diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index cbd9046..852ac10 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -7,6 +7,7 @@ import { CellVerticalAlign, getCollapsedCellBorderStyle, getDefaultCellPaddingStyle, + resolveConfiguredCellStyle, resolveCellVerticalAlign } from './helpers/tableStyles'; @@ -57,7 +58,7 @@ export default function useHtmlTableCellProps({ } = propsFromParent as InternalTableCellPropsFromParent; const styleFromConfig = resolvedCellStyle ? resolvedCellStyle.configStyle - : config?.getStyleForCell?.call(null, cell); + : resolveConfiguredCellStyle(config?.getStyleForCell?.call(null, cell)); const verticalAlign = resolveCellVerticalAlign(props.tnode); // Vertical table-cell alignment and horizontal colspan centering are // independent, so keep both declarations in the same style contribution. From b5063947a6294fc13a6e6e53149e7e90580ceffb Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 18:32:31 +0200 Subject: [PATCH 11/21] fix(heuristic-table-plugin): adjust tests --- .../__tests__/HTMLTable.test.tsx | 11 +- .../__tests__/TableLayout.test.ts | 8 +- .../__tests__/layoutStyles.test.ts | 8 +- .../src/__tests__/tableRendering.test.tsx | 252 ++++++++++++++++++ .../src/__tests__/tsconfig.json | 9 + .../__tests__/useHtmlTableCellProps.test.ts | 120 ++++++++- .../src/__tests__/useHtmlTableProps.test.tsx | 95 +++++++ .../src/{helpers => }/__tests__/utils.ts | 2 +- .../TCellConstraintsComputer.test.ts | 2 +- .../__tests__/createRenderTree.test.ts | 2 +- .../__tests__/fillTableDisplay.test.ts | 2 +- .../__tests__/resolveAvailableWidth.test.ts | 2 +- .../helpers/__tests__/resolveWidth.test.ts | 133 +++++++++ .../helpers/__tests__/tableRendering.test.tsx | 176 ------------ .../src/helpers/__tests__/tableStyles.test.ts | 26 +- 15 files changed, 632 insertions(+), 216 deletions(-) rename packages/heuristic-table-plugin/src/{helpers => }/__tests__/HTMLTable.test.tsx (94%) rename packages/heuristic-table-plugin/src/{helpers => }/__tests__/TableLayout.test.ts (99%) rename packages/heuristic-table-plugin/src/{helpers => }/__tests__/layoutStyles.test.ts (98%) create mode 100644 packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx create mode 100644 packages/heuristic-table-plugin/src/__tests__/tsconfig.json rename packages/heuristic-table-plugin/src/{helpers => }/__tests__/useHtmlTableCellProps.test.ts (54%) create mode 100644 packages/heuristic-table-plugin/src/__tests__/useHtmlTableProps.test.tsx rename packages/heuristic-table-plugin/src/{helpers => }/__tests__/utils.ts (97%) create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/resolveWidth.test.ts delete mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx b/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx similarity index 94% rename from packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx rename to packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx index c9e7f37..416aed6 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/HTMLTable.test.tsx +++ b/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx @@ -1,16 +1,13 @@ import React, { PropsWithChildren } from 'react'; import { render } from '@testing-library/react-native'; import { ScrollView, StyleSheet, View, ViewStyle } from 'react-native'; -import HTMLTable from '../../HTMLTable'; -import TableLayout from '../../TableLayout'; -import { - HeuristicTablePluginConfig, - HTMLTableProps -} from '../../shared-types'; +import HTMLTable from '../HTMLTable'; +import TableLayout from '../TableLayout'; +import { HeuristicTablePluginConfig, HTMLTableProps } from '../shared-types'; import { createTableTNode } from './utils'; // Inspect the real table wrapper and scroll container independently of cell rendering. -jest.mock('../../TreeRenderer', () => () => null); +jest.mock('../TreeRenderer', () => () => null); function DefaultRenderer({ children, diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts similarity index 99% rename from packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts rename to packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts index 420d655..2c54c17 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TableLayout.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts @@ -1,7 +1,7 @@ -import TableLayout from '../../TableLayout'; -import { shouldScrollTable } from '../../HTMLTable'; -import { Settings } from '../../shared-types'; -import reduceColumnConstraints from '../reduceColumnConstraints'; +import TableLayout from '../TableLayout'; +import { shouldScrollTable } from '../HTMLTable'; +import { Settings } from '../shared-types'; +import reduceColumnConstraints from '../helpers/reduceColumnConstraints'; import { createTableTNode } from './utils'; function layoutFor(html: string, settings: Settings): TableLayout { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts b/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts similarity index 98% rename from packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts rename to packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts index 94e32fb..afe6a66 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/layoutStyles.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts @@ -1,9 +1,9 @@ import { CustomRendererProps, TBlock } from '@native-html/render'; import { ViewStyle } from 'react-native'; -import TableLayout from '../../TableLayout'; -import useHtmlTableCellProps from '../../useHtmlTableCellProps'; -import { shouldScrollTable } from '../../HTMLTable'; -import { Settings } from '../../shared-types'; +import TableLayout from '../TableLayout'; +import useHtmlTableCellProps from '../useHtmlTableCellProps'; +import { shouldScrollTable } from '../HTMLTable'; +import { Settings } from '../shared-types'; import { createTableTNode } from './utils'; /** diff --git a/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx b/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx new file mode 100644 index 0000000..38daeb1 --- /dev/null +++ b/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { StyleSheet, ViewStyle } from 'react-native'; +import RenderHTML, { CustomBlockRenderer } from '@native-html/render'; +import renderers from '../index'; +import { TableCell } from '../shared-types'; +import HTMLTable from '../HTMLTable'; +import TableLayout from '../TableLayout'; +import { getHorizontalInsets } from '../helpers/measure'; +import { DEFAULT_FONT_WEIGHT_COEFFS } from '../helpers/TCellConstraintsComputer'; +import useHtmlTableProps from '../useHtmlTableProps'; + +afterEach(() => jest.restoreAllMocks()); + +describe.each([true, false])( + 'callback padding with user-agent styles %s', + (enableUserAgentStyles) => { + it.each([ + [{ padding: 8 }, '', 8, 8], + [{ paddingHorizontal: 8 }, '', 8, 8], + [{ padding: 0 }, '', 0, 0], + [{ padding: 8, paddingLeft: 3 }, '', 3, 8], + [{ padding: 8 }, 'padding-left:4px', 8, 8], + [{ paddingVertical: 8 }, 'padding-left:3px;padding-right:5px', 3, 5] + ] as [ViewStyle, string, number, number][])( + 'measures and renders %j over source %s', + (padding, sourceStyle, left, right) => { + const getStyleForCell = jest.fn(() => padding); + const rendered = render( + A` + }} + renderers={renderers} + renderersProps={{ + table: { + forceStretch: false, + baseFontCoeff: 0.5, + getStyleForCell + } + }} + /> + ); + const cell = rendered.getByTestId('td'); + const width = 10 + left + right; + expect(cell).toHaveStyle({ + paddingLeft: left, + paddingRight: right, + width + }); + if (padding.paddingVertical != null) { + expect(cell).toHaveStyle({ paddingTop: 8, paddingBottom: 8 }); + } + let wrapper = cell.parent; + while (wrapper && typeof wrapper.type !== 'string') + wrapper = wrapper.parent; + expect(StyleSheet.flatten(wrapper!.props.style).width).toBe(width); + expect(getStyleForCell).toHaveBeenCalledTimes(1); + } + ); + } +); + +describe('user-agent cell styles', () => { + function cellStyles(html: string, enableUserAgentStyles?: boolean) { + const rendered = render( + + ); + const styleOf = (testID: string) => + StyleSheet.flatten(rendered.getByTestId(testID).props.style); + return { td: styleOf('td'), th: styleOf('th') }; + } + + const HEADED = '
MMMMMMMM
'; + // Four characters of the default 14px text at the pinned 0.5 coefficient. + const TEXT_WIDTH = 4 * 14 * 0.5; + + // `th` is bold in the user-agent stylesheet, so every header cell of every + // default-configured table is measured through the font-weight coefficients + // — the one production path that reaches them without an author saying so. + it.each([undefined, true])( + 'measures a bold th wider than a td with enableUserAgentStyles %s', + (enableUserAgentStyles) => { + const { td, th } = cellStyles(HEADED, enableUserAgentStyles); + // The engine's user-agent sheet pads a cell by 2px on each side, which + // is what ships: the plugin's own 1px default only fills sides that + // sheet leaves bare, and here it leaves none. + expect(td).toMatchObject({ paddingLeft: 2, paddingRight: 2 }); + expect(th).toMatchObject({ paddingLeft: 2, paddingRight: 2 }); + expect(td.width).toBeCloseTo(TEXT_WIDTH + 4); + expect(th.width).toBeCloseTo( + TEXT_WIDTH * DEFAULT_FONT_WEIGHT_COEFFS.bold! + 4 + ); + expect(th.width).toBeGreaterThan(td.width); + } + ); + + it('measures th and td alike when user-agent styles are off', () => { + // Nothing declares a weight now, so the header loses its bold coefficient + // and both cells fall back to the plugin's own 1px of padding. + const { td, th } = cellStyles(HEADED, false); + expect(td).toMatchObject({ paddingLeft: 1, paddingRight: 1 }); + expect(th).toMatchObject({ paddingLeft: 1, paddingRight: 1 }); + expect(th.width).toBeCloseTo(TEXT_WIDTH + 2); + expect(th.width).toBe(td.width); + }); +}); + +describe('nested tables', () => { + // `getStyleForCell` gives every cell 4px of padding and a 2px border on each + // side. The outer first cell is at x=0 of two columns, so collapsing hands + // its leading edge to the table wrapper and leaves the trailing one to it: + // 4 + 4 of padding, 0 of left border and 2 of right. + const CELL_HORIZONTAL_INSETS = 4 + 4 + 0 + 2; + + it.each([0, 10])( + 'sizes nested tables inside their assigned cell and %spx wrapper padding', + (padding) => { + const source = { + html: `
A
B
` + }; + const layouts = new Map(); + const TableRenderer: CustomBlockRenderer = (props) => { + const tableProps = useHtmlTableProps(props); + layouts.set(props.tnode.attributes.id!, tableProps.layout); + return ; + }; + const testRenderers = { ...renderers, table: TableRenderer }; + const getStyleForCell = () => ({ padding: 4, borderWidth: 2 }); + const view = (contentWidth: number) => ( + + ); + // RenderHTML warns in dev when its props change less than 60ms apart, and + // the deliberate rerender below is immediate. Pinning the clock and moving + // it on by a second per prop change keeps that warning out of the output. + const now = jest.spyOn(performance, 'now').mockReturnValue(1000); + const rendered = render(view(400)); + const checkWidths = () => { + const outer = layouts.get('outer'); + const inner = layouts.get('inner'); + const parent = outer!.cells[0]!; + // The measurement pass and the cell renderer merge the source, config + // and collapsed styles in two separate places. Both are pinned to the + // same hand-computed insets: reading the expectation back out of the + // layout would let the two drift together undetected, and a nested + // table would then be sized against a box its cell does not have. + expect( + getHorizontalInsets(outer!.cellStyles.get(parent.tnode)!.style) + ).toBe(CELL_HORIZONTAL_INSETS); + expect( + getHorizontalInsets( + StyleSheet.flatten(rendered.getAllByTestId('td')[0]!.props.style) + ) + ).toBe(CELL_HORIZONTAL_INSETS); + const expected = parent.width - CELL_HORIZONTAL_INSETS - 2 * padding; + expect(inner!.availableWidth).toBeCloseTo(expected); + expect(inner!.usedWidth).toBeCloseTo(expected); + return expected; + }; + const initialWidth = checkWidths(); + now.mockReturnValue(2000); + rendered.rerender(view(600)); + expect(checkWidths()).toBeGreaterThan(initialWidth); + } + ); +}); + +describe('measured styles across rerenders', () => { + it('reuses measured callback styles and relayouts when the callback changes', () => { + const source = { + html: '
AB
' + }; + const first = jest.fn((cell: TableCell) => ({ + paddingLeft: cell.x === 0 ? 8 : 4, + paddingRight: cell.x === 0 ? 8 : 4 + })); + const second = jest.fn(() => ({ paddingLeft: 12, paddingRight: 12 })); + const view = (getStyleForCell: typeof first | typeof second) => ( + + ); + // Advance RenderHTML's profiler clock between the intentional prop changes + // below, which would otherwise be warned about as accidental rerenders. + const now = jest.spyOn(performance, 'now'); + now.mockReturnValue(1000); + const rendered = render(view(first)); + const expectCellStyles = (paddings: number[], widths: number[]) => { + const cells = rendered.getAllByTestId('td'); + expect(cells).toHaveLength(2); + cells.forEach((cell, index) => { + expect(cell).toHaveStyle({ + paddingLeft: paddings[index], + paddingRight: paddings[index], + width: widths[index] + }); + // Skip composite components to inspect TreeRenderer's surrounding native + // View as well as the cell itself. Both must receive the measured width. + let wrapper = cell.parent; + while (wrapper && typeof wrapper.type !== 'string') + wrapper = wrapper.parent; + expect(wrapper).not.toBeNull(); + expect(StyleSheet.flatten(wrapper!.props.style).width).toBe( + widths[index] + ); + }); + }; + // 20px text at the pinned 0.5 coefficient is 10px for the single + // character of each cell, plus the padding the callback declares. + expectCellStyles([8, 4], [10 + 8 + 8, 10 + 4 + 4]); + expect(rendered.queryByText('A')).not.toBeNull(); + expect(rendered.queryByText('B')).not.toBeNull(); + expect(first).toHaveBeenCalledTimes(2); + now.mockReturnValue(2000); + rendered.rerender(view(first)); + expect(first).toHaveBeenCalledTimes(2); + expectCellStyles([8, 4], [10 + 8 + 8, 10 + 4 + 4]); + now.mockReturnValue(3000); + rendered.rerender(view(second)); + now.mockRestore(); + expectCellStyles([12, 12], [10 + 12 + 12, 10 + 12 + 12]); + expect(second).toHaveBeenCalledTimes(2); + expect(first).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/heuristic-table-plugin/src/__tests__/tsconfig.json b/packages/heuristic-table-plugin/src/__tests__/tsconfig.json new file mode 100644 index 0000000..c8a11dd --- /dev/null +++ b/packages/heuristic-table-plugin/src/__tests__/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig-base.json", + "compilerOptions": { + "types": ["jest"], + "noEmit": true, + "ignoreDeprecations": "6.0" + }, + "exclude": ["../../node_modules", "../../lib"] +} diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts b/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts similarity index 54% rename from packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts rename to packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts index 53adf31..2cf3ffb 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/useHtmlTableCellProps.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts @@ -1,9 +1,27 @@ import { ViewStyle } from 'react-native'; import { CustomRendererProps, TBlock } from '@native-html/render'; -import useHtmlTableCellProps from '../../useHtmlTableCellProps'; -import { HeuristicTablePluginConfig, TableCell } from '../../shared-types'; +import useHtmlTableCellProps from '../useHtmlTableCellProps'; +import { HeuristicTablePluginConfig, TableCell } from '../shared-types'; import { createCellTNode } from './utils'; +/** + * Where the cell sits in the matrix, and how the table collapses its borders. + * + * @remarks + * Only a consumer writing its own `td` renderer reaches this shape: + * `TreeRenderer` always hands down a `resolvedCellStyle` it measured against, + * and the hook then reuses that instead of resolving anything here. The + * defaults keep the cell the sole one of a separate-border table. + */ +interface CellContext { + borderCollapse?: boolean; + x?: number; + y?: number; + maxX?: number; + maxY?: number; + tableBorderStyle?: ViewStyle | null; +} + /** * The style the cell renderer hands to the default renderer for the first cell * of `cellMarkup`. @@ -15,14 +33,22 @@ import { createCellTNode } from './utils'; */ function cellStyleFor( cellMarkup: string, - config: HeuristicTablePluginConfig = {} + config: HeuristicTablePluginConfig = {}, + { + borderCollapse = false, + x = 0, + y = 0, + maxX = 0, + maxY = 0, + tableBorderStyle = null + }: CellContext = {} ): ViewStyle { const tnode = createCellTNode(`${cellMarkup}
`); const cell: TableCell = { type: 'cell', tnode, - x: 0, - y: 0, + x, + y, lenX: 1, lenY: 1, width: 100, @@ -34,10 +60,10 @@ function cellStyleFor( propsFromParent: { cell, config, - borderCollapse: false, - maxX: 0, - maxY: 0, - tableBorderStyle: null + borderCollapse, + maxX, + maxY, + tableBorderStyle } } as unknown as CustomRendererProps; return useHtmlTableCellProps(props).style as ViewStyle; @@ -140,4 +166,80 @@ describe('useHtmlTableCellProps', () => { }); }); }); + + // The hook resolves the collapsing model itself only when no + // `resolvedCellStyle` reaches it, which `TreeRenderer` always supplies. This + // is therefore the path of a consumer rendering its own `td`, and the one + // branch of the hook the in-tree renderers never take. + describe('collapsing borders without a resolved style', () => { + const BORDERED = 'A'; + + it('leaves the borders of a separate-border cell alone', () => { + expect(cellStyleFor(BORDERED)).toMatchObject({ + borderLeftWidth: 2, + borderTopWidth: 2, + borderRightWidth: 2, + borderBottomWidth: 2 + }); + }); + + it('drops the duplicated leading and top edges of an interior cell', () => { + expect( + cellStyleFor( + BORDERED, + {}, + { + borderCollapse: true, + x: 1, + y: 1, + maxX: 2, + maxY: 2 + } + ) + ).toMatchObject({ + borderLeftWidth: 0, + borderTopWidth: 0, + borderRightWidth: 2, + borderBottomWidth: 2 + }); + }); + + it('weighs a border the cell only gets from the config', () => { + // Resolving against the source CSS alone would find no border here and + // strip the one `getStyleForCell` declares, leaving nothing to paint it. + expect( + cellStyleFor( + 'A', + { getStyleForCell: () => ({ borderWidth: 3, borderColor: 'red' }) }, + { borderCollapse: true, x: 0, y: 0, maxX: 1, maxY: 0 } + ) + ).toMatchObject({ + borderRightWidth: 3, + borderRightColor: 'red' + }); + }); + + it('yields an outer edge to a table wrapper that paints it', () => { + expect( + cellStyleFor( + BORDERED, + {}, + { + borderCollapse: true, + tableBorderStyle: { + borderTopWidth: 2, + borderRightWidth: 2, + borderBottomWidth: 2, + borderLeftWidth: 2 + } + } + ) + ).toMatchObject({ + borderLeftWidth: 0, + borderTopWidth: 0, + borderRightWidth: 0, + borderBottomWidth: 0 + }); + }); + }); }); diff --git a/packages/heuristic-table-plugin/src/__tests__/useHtmlTableProps.test.tsx b/packages/heuristic-table-plugin/src/__tests__/useHtmlTableProps.test.tsx new file mode 100644 index 0000000..5933423 --- /dev/null +++ b/packages/heuristic-table-plugin/src/__tests__/useHtmlTableProps.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import RenderHTML, { CustomBlockRenderer } from '@native-html/render'; +import renderers from '../index'; +import HTMLTable from '../HTMLTable'; +import TableLayout from '../TableLayout'; +import useHtmlTableProps from '../useHtmlTableProps'; + +/** + * Render `html` with a table renderer that passes `overrideContentWidth` for + * the table of the given `id`, and return every layout by table id. + * + * @remarks + * The option is only reachable through a consumer's own table renderer, so it + * is exercised the way one would use it rather than by calling the hook bare. + */ +function layoutsFor( + html: string, + contentWidth: number, + override?: { id: string; width: number } +) { + const layouts = new Map(); + const configs: unknown[] = []; + const TableRenderer: CustomBlockRenderer = (props) => { + const id = props.tnode.attributes.id!; + const tableProps = useHtmlTableProps( + props, + override?.id === id ? { overrideContentWidth: override.width } : {} + ); + layouts.set(id, tableProps.layout); + configs.push(tableProps.config); + return ; + }; + render( + + ); + return { layouts, configs }; +} + +const SINGLE = '
AB
'; +// The inner table sits in a cell, so a `CellContentWidthContext` is in scope +// for it and an override has something to take precedence over. +const NESTED = ` + +
A
B
`; + +describe('useHtmlTableProps', () => { + it('yields an empty config when the renderer is given no table props', () => { + // `renderersProps.table` is optional, and `HTMLTable` reads `config` + // unconditionally: handing it `undefined` would throw on the first lookup. + const { configs } = layoutsFor(SINGLE, 400); + expect(configs).toHaveLength(1); + expect(configs[0]).toEqual({}); + }); + + describe('overrideContentWidth', () => { + it('lays out against the shared content width when absent', () => { + const layout = layoutsFor(SINGLE, 400).layouts.get('only')!; + expect(layout.availableWidth).toBe(400); + expect(layout.totalWidth).toBeCloseTo(400); + }); + + it('replaces the shared content width when given', () => { + const layout = layoutsFor(SINGLE, 400, { + id: 'only', + width: 250 + }).layouts.get('only')!; + expect(layout.availableWidth).toBe(250); + expect(layout.totalWidth).toBeCloseTo(250); + }); + + it('sizes a nested table from its cell when absent', () => { + const { layouts } = layoutsFor(NESTED, 400); + const cell = layouts.get('outer')!.cells[0]!; + expect(layouts.get('inner')!.availableWidth).toBeLessThan(cell.width); + }); + + it('overrides the containing cell of a nested table when given', () => { + // The option exists so a consumer may size a table against something + // other than the box it sits in, so the cell content width — which is + // narrower than the whole table — must not win over it here. + const { layouts } = layoutsFor(NESTED, 400, { id: 'inner', width: 320 }); + expect(layouts.get('outer')!.cells[0]!.width).toBeLessThan(320); + // The override replaces the content width the table starts from, not the + // spacing its ancestors impose: the containing cell still charges the + // 2px of user-agent padding it spends on each side. + expect(layouts.get('inner')!.availableWidth).toBe(320 - 4); + expect(layouts.get('inner')!.totalWidth).toBeCloseTo(316); + }); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts b/packages/heuristic-table-plugin/src/__tests__/utils.ts similarity index 97% rename from packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts rename to packages/heuristic-table-plugin/src/__tests__/utils.ts index 645bc3d..78e93f2 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/utils.ts +++ b/packages/heuristic-table-plugin/src/__tests__/utils.ts @@ -1,6 +1,6 @@ import { TRenderEngine } from '@native-html/transient-render-engine'; import { TNode } from '@native-html/render'; -import colgroupModel from '../../ColgroupModel'; +import colgroupModel from '../ColgroupModel'; const engine = new TRenderEngine({ // Unit fixtures isolate plugin defaults. Public-renderer tests retain the diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index 9dba2bb..2be5220 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -4,7 +4,7 @@ import TCellConstraintsComputer, { } from '../TCellConstraintsComputer'; import { TCellConstraints } from '../../shared-types'; import { DEFAULT_CELL_PADDING } from '../tableStyles'; -import { createCellTNode } from './utils'; +import { createCellTNode } from '../../__tests__/utils'; /** * Pinned here so that the break-opportunity assertions below test the segment diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts index ceb892f..d301497 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts @@ -1,5 +1,5 @@ import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; -import { createTableTNode } from './utils'; +import { createTableTNode } from '../../__tests__/utils'; import createRenderTree, { makeTableCells } from '../createRenderTree'; import TCellConstraintsComputer from '../TCellConstraintsComputer'; import { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts index b20e690..4a531c9 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts @@ -1,7 +1,7 @@ import { TNode } from '@native-html/render'; import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; import TCellConstraintsComputer from '../TCellConstraintsComputer'; -import { createTableTNode } from './utils'; +import { createTableTNode } from '../../__tests__/utils'; function createDisplay(tnode: TNode) { const display = createEmptyDisplay({ contentWidth: 1000 }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts index f863b61..943412f 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveAvailableWidth.test.ts @@ -1,5 +1,5 @@ import resolveAvailableWidth from '../resolveAvailableWidth'; -import { createTableTNode } from './utils'; +import { createTableTNode } from '../../__tests__/utils'; function availableWidthFor(html: string, contentWidth: number, nth = 0) { return resolveAvailableWidth(createTableTNode(html, nth), contentWidth); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/resolveWidth.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveWidth.test.ts new file mode 100644 index 0000000..f3cbc69 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/resolveWidth.test.ts @@ -0,0 +1,133 @@ +import { + clampWidth, + lesserBound, + resolveAttributeLength, + resolveAttributeSize, + resolveCssSize, + resolvePercentage +} from '../resolveWidth'; + +describe('resolveCssSize', () => { + it.each([ + [200, 200], + [0, 0], + [12.5, 12.5] + ])('should pass the absolute length %s through', (value, expected) => { + expect(resolveCssSize(value, 400)).toBe(expected); + }); + + it.each([-1, NaN, Infinity, -Infinity])( + 'should reject the unusable number %s', + (value) => { + // A width that cannot be laid out must not reach the column solver as a + // constraint: a negative one would shrink a column below its content and + // a non-finite one would poison every sum it takes part in. + expect(resolveCssSize(value, 400)).toBeNull(); + } + ); + + it.each([ + ['50%', 200], + ['12.5%', 50], + ['0%', 0], + [' 25% ', 100] + ])('should resolve %s against the containing block', (value, expected) => { + expect(resolveCssSize(value, 400)).toBe(expected); + }); + + it.each(['auto', '10em', '20px', '%', 'NaN%', '', 'inherit'])( + 'should reject the unresolvable string %s', + (value) => { + // Lengths reach the plugin already converted to numbers, so a string + // that is not a percentage carries no width this pass can use. + expect(resolveCssSize(value, 400)).toBeNull(); + } + ); + + it.each([undefined, null, {}, []])( + 'should reject the non-size value %s', + (value) => { + expect(resolveCssSize(value, 400)).toBeNull(); + } + ); +}); + +describe('resolvePercentage', () => { + it('should return a ratio rather than a resolved length', () => { + expect(resolvePercentage('25%')).toBe(0.25); + }); + + it.each([200, 'auto', '200', undefined])( + 'should report no ratio for %s', + (value) => { + expect(resolvePercentage(value)).toBeNull(); + } + ); +}); + +describe('resolveAttributeLength', () => { + it.each([ + ['200', 200], + ['0', 0], + [' 42 ', 42] + ])('should read the unitless attribute %s', (value, expected) => { + expect(resolveAttributeLength(value)).toBe(expected); + }); + + it.each(['50%', '200px', 'abc', '-5', ''])( + 'should refuse the attribute %s, which is not a bare number', + (value) => { + expect(resolveAttributeLength(value)).toBeNull(); + } + ); +}); + +describe('resolveAttributeSize', () => { + it('should resolve a percentage attribute against the containing block', () => { + expect(resolveAttributeSize('50%', 400)).toBe(200); + }); + + it('should fall back to the unitless form', () => { + expect(resolveAttributeSize('200', 400)).toBe(200); + }); + + it.each(['abc', '200px', 42])('should refuse %s', (value) => { + expect(resolveAttributeSize(value, 400)).toBeNull(); + }); +}); + +describe('clampWidth', () => { + it('should leave a width inside both bounds alone', () => { + expect(clampWidth(150, 100, 200)).toBe(150); + }); + + it('should cut a width down to its maximum', () => { + expect(clampWidth(300, null, 200)).toBe(200); + }); + + it('should raise a width up to its minimum', () => { + expect(clampWidth(50, 100, null)).toBe(100); + }); + + it('should let the minimum win over a smaller maximum', () => { + // CSS applies `max-width` first and `min-width` second, so a floor above + // the ceiling wins — the order of the two clamps is the whole behaviour. + expect(clampWidth(150, 200, 100)).toBe(200); + }); + + it('should pass a width through when neither bound is declared', () => { + expect(clampWidth(150, null, null)).toBe(150); + }); +}); + +describe('lesserBound', () => { + it.each([ + [100, 200, 100], + [200, 100, 100], + [null, 200, 200], + [100, null, 100], + [null, null, null] + ])('should take the stricter of %s and %s', (a, b, expected) => { + expect(lesserBound(a, b)).toBe(expected); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx b/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx deleted file mode 100644 index 6ed4d70..0000000 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableRendering.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import { StyleSheet, ViewStyle } from 'react-native'; -import RenderHTML, { CustomBlockRenderer } from '@native-html/render'; -import renderers from '../../index'; -import { TableCell } from '../../shared-types'; -import HTMLTable from '../../HTMLTable'; -import TableLayout from '../../TableLayout'; -import { getHorizontalInsets } from '../measure'; -import useHtmlTableProps from '../../useHtmlTableProps'; - -afterEach(() => jest.restoreAllMocks()); - -describe.each([true, false])( - 'callback padding with user-agent styles %s', - (enableUserAgentStyles) => { - it.each([ - [{ padding: 8 }, '', 8, 8], - [{ paddingHorizontal: 8 }, '', 8, 8], - [{ padding: 0 }, '', 0, 0], - [{ padding: 8, paddingLeft: 3 }, '', 3, 8], - [{ padding: 8 }, 'padding-left:4px', 8, 8], - [{ paddingVertical: 8 }, 'padding-left:3px;padding-right:5px', 3, 5] - ] as [ViewStyle, string, number, number][])( - 'measures and renders %j over source %s', - (padding, sourceStyle, left, right) => { - const getStyleForCell = jest.fn(() => padding); - const rendered = render( - A` - }} - renderers={renderers} - renderersProps={{ - table: { - forceStretch: false, - baseFontCoeff: 0.5, - getStyleForCell - } - }} - /> - ); - const cell = rendered.getByTestId('td'); - const width = 10 + left + right; - expect(cell).toHaveStyle({ - paddingLeft: left, - paddingRight: right, - width - }); - if (padding.paddingVertical != null) { - expect(cell).toHaveStyle({ paddingTop: 8, paddingBottom: 8 }); - } - let wrapper = cell.parent; - while (wrapper && typeof wrapper.type !== 'string') - wrapper = wrapper.parent; - expect(StyleSheet.flatten(wrapper!.props.style).width).toBe(width); - expect(getStyleForCell).toHaveBeenCalledTimes(1); - } - ); - } -); - -it.each([0, 10])( - 'sizes nested tables inside their assigned cell and %spx wrapper padding', - (padding) => { - const source = { - html: `
A
B
` - }; - const layouts = new Map(); - const TableRenderer: CustomBlockRenderer = (props) => { - const tableProps = useHtmlTableProps(props); - layouts.set(props.tnode.attributes.id!, tableProps.layout); - return ; - }; - const testRenderers = { ...renderers, table: TableRenderer }; - const getStyleForCell = () => ({ padding: 4, borderWidth: 2 }); - const view = (contentWidth: number) => ( - - ); - // RenderHTML warns in dev when its props change less than 60ms apart, and - // the deliberate rerender below is immediate. Pinning the clock and moving - // it on by a second per prop change keeps that warning out of the output. - const now = jest.spyOn(performance, 'now').mockReturnValue(1000); - const rendered = render(view(400)); - const checkWidths = () => { - const outer = layouts.get('outer'); - const inner = layouts.get('inner'); - const parent = outer!.cells[0]!; - const expected = - parent.width - - getHorizontalInsets(outer!.cellStyles.get(parent.tnode)!.style) - - 2 * padding; - expect(inner!.availableWidth).toBeCloseTo(expected); - expect(inner!.usedWidth).toBeCloseTo(expected); - return expected; - }; - const initialWidth = checkWidths(); - now.mockReturnValue(2000); - rendered.rerender(view(600)); - expect(checkWidths()).toBeGreaterThan(initialWidth); - } -); - -it('reuses measured callback styles while rendering and relayouts when the callback changes', () => { - const source = { - html: '
AB
' - }; - const first = jest.fn((cell: TableCell) => ({ - paddingLeft: cell.x === 0 ? 8 : 4, - paddingRight: cell.x === 0 ? 8 : 4 - })); - const second = jest.fn(() => ({ paddingLeft: 12, paddingRight: 12 })); - const view = (getStyleForCell: typeof first | typeof second) => ( - - ); - // Advance RenderHTML's profiler clock between the intentional prop changes - // below, which would otherwise be warned about as accidental rerenders. - const now = jest.spyOn(performance, 'now'); - now.mockReturnValue(1000); - const rendered = render(view(first)); - const expectCellStyles = (paddings: number[], widths: number[]) => { - const cells = rendered.getAllByTestId('td'); - expect(cells).toHaveLength(2); - cells.forEach((cell, index) => { - expect(cell).toHaveStyle({ - paddingLeft: paddings[index], - paddingRight: paddings[index], - width: widths[index] - }); - // Skip composite components to inspect TreeRenderer's surrounding native - // View as well as the cell itself. Both must receive the measured width. - let wrapper = cell.parent; - while (wrapper && typeof wrapper.type !== 'string') - wrapper = wrapper.parent; - expect(wrapper).not.toBeNull(); - expect(StyleSheet.flatten(wrapper!.props.style).width).toBe( - widths[index] - ); - }); - }; - expectCellStyles([8, 4], [26, 18]); - expect(rendered.getByText('A')).toBeTruthy(); - expect(rendered.getByText('B')).toBeTruthy(); - expect(first).toHaveBeenCalledTimes(2); - now.mockReturnValue(2000); - rendered.rerender(view(first)); - expect(first).toHaveBeenCalledTimes(2); - expectCellStyles([8, 4], [26, 18]); - now.mockReturnValue(3000); - rendered.rerender(view(second)); - now.mockRestore(); - expectCellStyles([12, 12], [34, 34]); - expect(second).toHaveBeenCalledTimes(2); - expect(first).toHaveBeenCalledTimes(2); -}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts index adfacba..b1c2ce9 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts @@ -7,7 +7,7 @@ import { resolveCellVerticalAlign } from '../tableStyles'; import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; -import { createCellTNode, createTableTNode } from './utils'; +import { createCellTNode, createTableTNode } from '../../__tests__/utils'; /** A wrapper that paints all four of its resolved outer edges. */ const FRAMED = { @@ -494,19 +494,23 @@ describe('table styles', () => { const LOGICAL_START = { borderStartWidth: 7, borderStartColor: 'red' }; it.each([ - [false, 'borderLeftWidth'], - [true, 'borderRightWidth'] + [false, 'borderLeftWidth', 'borderRightWidth'], + [true, 'borderRightWidth', 'borderLeftWidth'] ] as const)( 'resolves a logical start edge with isRTL %s', - (isRTL, physicalSide) => { + (isRTL, physicalSide, oppositeSide) => { jest.replaceProperty(I18nManager, 'isRTL', isRTL); - expect( - getCollapsedCellBorderStyle( - { x: 0, y: 0, lenX: 1, lenY: 1 }, - LOGICAL_START, - { maxX: 0, maxY: 0, tableBorderStyle: null } - ) - ).toMatchObject({ [physicalSide]: 7 }); + const style = getCollapsedCellBorderStyle( + { x: 0, y: 0, lenX: 1, lenY: 1 }, + LOGICAL_START, + { maxX: 0, maxY: 0, tableBorderStyle: null } + ); + // The opposite side is asserted too: a logical edge resolved onto both + // physical ones would otherwise satisfy the winning side alone. + expect(style).toMatchObject({ + [physicalSide]: 7, + [oppositeSide]: 0 + }); } ); From da969db81f742d70e0909d37c891a36a4c21df7a Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 19:00:42 +0200 Subject: [PATCH 12/21] fix(heuristic-table-plugin): width resolution, surplus sharing and logical padding shorthands --- ...istictablepluginconfig.growbeyondheight.md | 18 +++++++++ ...table-plugin.heuristictablepluginconfig.md | 19 +++++++++ .../heuristic-table-plugin/src/TableLayout.ts | 12 ++++-- .../src/__tests__/TableLayout.test.ts | 26 +++++++++++++ .../src/__tests__/layoutStyles.test.ts | 39 +++++++++++++++++++ .../src/helpers/TCellConstraintsComputer.ts | 1 + .../src/helpers/computeColumnWidths.ts | 38 ++++++++++++++++-- .../src/helpers/fillTableDisplay.ts | 28 +++---------- .../src/helpers/tableStyles.ts | 21 ++++++++-- .../src/shared-types.ts | 16 ++++++++ 10 files changed, 187 insertions(+), 31 deletions(-) create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md new file mode 100644 index 0000000..f8f6160 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md) > [growBeyondHeight](./heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md) + +## HeuristicTablePluginConfig.growBeyondHeight property + +When true, an explicit `height` on the table, or on any of its cells, is treated as a minimum: the box still grows to fit content taller than it. When false, that `height` is enforced as written and taller content overflows it. + +**Signature:** + +```typescript +growBeyondHeight?: boolean; +``` + +## Remarks + +Per [CSS 2.1 §17.5.3](https://www.w3.org/TR/CSS21/tables.html#height-layout), `height` on a `table`, `tr`, `th` or `td` box is only a minimum, so `true` is the faithful reading of the HTML. It is off by default because React Native has no table layout algorithm to shrink a row back down, and a document whose markup sizes its tables is better served by a box that stays the size it asked for. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md index c32be52..7d6406c 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md @@ -110,6 +110,25 @@ boolean _(Optional)_ When true, the table stretches to fill the width its containing block offers — `contentWidth`, less the horizontal spacing of every ancestor. When false, a table with an auto width shrinks to fit its content. + + + +[growBeyondHeight?](./heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md) + + + + + + + +boolean + + + + +_(Optional)_ When true, an explicit `height` on the table, or on any of its cells, is treated as a minimum: the box still grows to fit content taller than it. When false, that `height` is enforced as written and taller content overflows it. + + diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 978bd3b..55ac4f1 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -72,11 +72,17 @@ export default class TableLayout { 0, containingWidth - getHorizontalMargins(style) ); - // A percentage table width resolves against the containing block, whereas - // the columns are laid out inside the table's own padding and border. + // Percentages resolve against the width the table may actually occupy, + // margins already deducted, rather than against the whole containing + // block. Resolving `width:100%` against the latter would hand the columns + // more width than the table box is allowed — by exactly the margins — and + // the surplus would then be shown through a horizontal scroller the same + // table without a declared width never gets. An absolute width is + // untouched by this and still overflows into that scroller when it does + // not fit, as it should. const { width, minWidth, maxWidth } = resolveWidthConstraints( tnode, - containingWidth + availableWidth ); const declaredTableWidth = width === null ? null : clampWidth(width, minWidth, maxWidth); diff --git a/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts index 2c54c17..0d9e822 100644 --- a/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts @@ -494,6 +494,32 @@ describe('TableLayout', () => { expect(totalWidth).toBeCloseTo(378); }); + it('should resolve a percentage table width inside its own margins', () => { + // A percentage may not resolve against width the margins have already + // spent: `width:100%` would then hand the columns the whole containing + // block while the table box is allowed only what is left of it, and the + // difference would surface as a scroller the very same table without a + // declared width never gets. + const { totalWidth, assignableWidth } = layoutFor( + `${rows}
`, + { contentWidth: 400, forceStretch: true } + ); + expect(assignableWidth).toBe(380); + expect(totalWidth).toBeCloseTo(380); + expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(false); + }); + + it('should still scroll an absolute width wider than the container', () => { + // Clamping the columns to the available width instead would silently + // drop the width the table asked for. + const { totalWidth, assignableWidth } = layoutFor( + `${rows}
`, + { contentWidth: 400 } + ); + expect(totalWidth).toBeCloseTo(800); + expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(true); + }); + it('should take the table own margins out of the width it may occupy', () => { const { assignableWidth, availableWidth } = layoutFor( `${rows}
`, diff --git a/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts b/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts index afe6a66..69a16f7 100644 --- a/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts @@ -253,9 +253,31 @@ describe('layout and resolved renderer styles', () => { expect(layout.totalWidth).toBeCloseTo(29.1); }); + it('gives identical collapsed cells identical content width', () => { + // Each cell draws its own trailing boundary while the wrapper draws the + // outer ones, so the cell in the last column carries one border fewer than + // its neighbours and its column is narrower by exactly that border. Never + // by more: the surplus is shared over content, so a bookkeeping difference + // in who paints a boundary cannot become a difference in content width. + const td = 'A'; + const layout = layoutFor( + `${td}${td}${td}${td}
`, + { contentWidth: 100, forceStretch: true } + ); + const [first, second, third, last] = layout.columnWidths; + expect(layout.horizontalInsets).toBe(4); + expect(second).toBeCloseTo(first!); + expect(third).toBeCloseTo(first!); + expect(last).toBeCloseTo(first! - 2); + expect(layout.totalWidth).toBeCloseTo(96); + }); + it.each([ [{ padding: 8 }, 16], [{ paddingHorizontal: 8 }, 16], + [{ paddingInline: 8 }, 16], + // Vertical padding leaves the horizontal sides to the user-agent default. + [{ paddingBlock: 8 }, 2], [{ paddingLeft: 8 }, 9], [{ padding: 0 }, 0], [{ paddingStart: 8 }, 8], @@ -288,6 +310,23 @@ describe('layout and resolved renderer styles', () => { }); }); + it('lets a callback logical padding shorthand override source longhands', () => { + // Yoga resolves `paddingInline` after the per-side edges, so a source + // `padding-left` would otherwise hold that one side and leave the callback + // shorthand painting only the other three. + const layout = layoutFor( + '
A
', + { + getStyleForCell: () => ({ paddingInline: 8 }) + } + ); + expect(layout.totalWidth).toBeCloseTo(9.1 + 8 + 8); + expect(renderedCellStyle(layout, 0)).toMatchObject({ + paddingLeft: 8, + paddingRight: 8 + }); + }); + it('uses callback borders for both outer and neighbouring collapsed edges', () => { const getStyleForCell = jest.fn((cell) => cell.x === 1 ? { borderWidth: 5, borderColor: 'red' } : null diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index f07e047..80e0339 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -297,6 +297,7 @@ export default class TCellConstraintsComputer { ); return { ...(percentWidth === null ? {} : { percentWidth }), + horizontalSpace: stats.horizontalSpace, minWidth, // `max-width` caps the width the cell would *like*, but never takes it // below the width it needs to hold its longest word: min-content is a diff --git a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts index 02ed30c..a6756e4 100644 --- a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts @@ -80,12 +80,24 @@ function normalizePercentages( * of them can take is left unassigned: a table whose every column is capped * ends up narrower than the width it was given, as it would in CSS, rather * than pushing a column past the ceiling it declared. + * + * The surplus is shared in proportion to how much *content* each column + * already holds, not to its whole border box, so that spacing a column + * happens to carry cannot earn it content width. It matters under the + * collapsing border model, where each cell owns a different subset of the + * boundaries around it — the cell in the last column has its trailing border + * painted by the table wrapper rather than by itself — and weighting by the + * border box would compound that difference instead of preserving it. + * + * @param insets - Each column's horizontal padding and border, which is held + * out of the weighting. Defaults to none. */ function addDistributedWidth( widths: number[], total: number, indexes: number[], - caps: Array + caps: Array, + insets: number[] = [] ): number[] { if (indexes.length === 0 || total <= 0) { return widths; @@ -100,7 +112,11 @@ function addDistributedWidth( while (remaining > EPSILON && candidates.length > 0) { const shares = distribute( remaining, - candidates.map((i) => result[i] ?? 0) + // A column holding nothing but its own spacing weighs nothing, and so + // waits while the columns with content grow. It is not stranded: once + // they have all reached their caps it is the only candidate left, and + // `distribute` shares evenly when every weight is zero. + candidates.map((i) => Math.max(0, (result[i] ?? 0) - (insets[i] ?? 0))) ); let consumed = 0; candidates.forEach((i, k) => { @@ -247,6 +263,22 @@ export default function computeColumnWidths( if (!shouldStretch) { return maxContentGuess; } + // The spacing each column carries, so that the surplus below is shared over + // content alone. A `colspan` spreads its own spacing across the columns it + // covers, as it does its intrinsic constraints, and where cells disagree the + // widest wins — the same reduction `minWidth` gets, which is the figure the + // spacing is being held out of. + const columnInsets = columnConstraints.map(() => 0); + for (const cell of display.cells) { + const share = (cell.constraints.horizontalSpace ?? 0) / cell.lenX; + const lastColumn = Math.min( + cell.x + cell.lenX - 1, + columnInsets.length - 1 + ); + for (let i = cell.x; i <= lastColumn; i++) { + columnInsets[i] = Math.max(columnInsets[i] ?? 0, share); + } + } const allColumns = maxContentGuess.map((_, i) => i); // A column that declared a width of its own already has the width it asked // for; the surplus belongs to the ones that left it to the table to decide. @@ -266,7 +298,7 @@ export default function computeColumnWidths( if (leftover <= EPSILON) { break; } - widths = addDistributedWidth(widths, leftover, group, caps); + widths = addDistributedWidth(widths, leftover, group, caps, columnInsets); } return widths; } diff --git a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts index 734588e..d30c163 100644 --- a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts +++ b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts @@ -13,8 +13,8 @@ import TCellConstraintsComputer from './TCellConstraintsComputer'; * @remarks * {@link fillTableDisplay} may be called without a computer, to lay the grid * out before the width its cells must be measured against is known. Every cell - * of such a display carries this placeholder until {@link measureDisplay} - * replaces it. + * of such a display carries this placeholder until the caller's measurement + * pass replaces it. */ const UNMEASURED_CONSTRAINTS: TCellConstraints = Object.freeze({ contentDensity: 0, @@ -86,8 +86,10 @@ function findFreeSlotX(display: Display, fromX: number, y: number): number { * @param computer - Measures each cell as it is laid down. Omit it to build * the grid alone — coordinates and spans do not depend on the width the table * resolves to, whereas constraints do, and measuring text is the costly half - * of a layout pass. Pass the display to {@link measureDisplay} once that width - * is known. + * of a layout pass. Measure the cells once that width is known, as + * {@link TableLayout} does: the collapsing border model has to resolve the + * table's own borders — from cell coordinates alone — before the width those + * cells are measured against exists. */ export default function fillTableDisplay( tnode: TNode, @@ -138,21 +140,3 @@ export default function fillTableDisplay( ); } } - -/** - * Measure every cell of an already laid out display. - * - * @remarks - * The counterpart to calling {@link fillTableDisplay} without a computer: the - * collapsing border model has to resolve the table's own borders — from cell - * coordinates alone — before the width those cells are measured against - * exists. Splitting the two keeps the grid walked once either way. - */ -export function measureDisplay( - display: Display, - computer: TCellConstraintsComputer -) { - for (const cell of display.cells) { - cell.constraints = computer.computeCellConstraints(cell.tnode); - } -} diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index 889dfe6..03da492 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -191,13 +191,28 @@ export function getDefaultCellPaddingStyle( return resolvedStyle; } -/** Expand callback shorthands so resolved source longhands cannot mask them. */ +/** + * Expand callback shorthands so resolved source longhands cannot mask them. + * + * @remarks + * Every shorthand Yoga resolves *after* a per-side edge has to be expanded + * here, the logical `paddingInline` / `paddingBlock` pair included: a cell + * declaring `padding-left` in its source CSS reaches the merge as a longhand, + * which would otherwise win on that one side and leave the callback's + * shorthand painting the other three — the opposite of the documented rule + * that callback padding replaces source padding outright. + * + * The per-side logical properties (`paddingStart`, `paddingInlineEnd` and + * friends) need no expansion: Yoga already resolves them ahead of the physical + * longhands, so they mask the source rather than being masked by it. + */ export function resolveConfiguredCellStyle( style: ViewStyle | null | undefined ): ViewStyle | null { if (!style) return null; - const horizontal = style.paddingHorizontal ?? style.padding; - const vertical = style.paddingVertical ?? style.padding; + const horizontal = + style.paddingInline ?? style.paddingHorizontal ?? style.padding; + const vertical = style.paddingBlock ?? style.paddingVertical ?? style.padding; return { ...(horizontal != null ? { paddingLeft: horizontal, paddingRight: horizontal } diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index e507373..17bc526 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -57,6 +57,22 @@ export interface TColumnConstraints extends TConstraintsBase { export interface TCellConstraints extends TConstraintsBase { /** Preferred fraction of the table width, resolved during distribution. */ percentWidth?: number; + /** + * The cell's own horizontal padding and border, already included in + * {@link TConstraintsBase.minWidth} and {@link TCellConstraints.maxWidth}. + * + * @remarks + * Reported separately so that distribution can tell a column's content + * apart from the spacing wrapped around it. Surplus width is shared over + * content alone: under the collapsing border model each cell owns a + * different set of the boundaries it touches, and weighting the share by the + * whole border box would turn that bookkeeping difference into a visible + * one, dealing a column that merely paints one more border edge more content + * width than its neighbours. + * + * @defaultValue 0, when a caller builds constraints by hand. + */ + horizontalSpace?: number; /** * The width at which this cell would stop benefiting from more space — the * *maximum cell width* of {@link https://www.w3.org/TR/CSS21/tables.html#auto-table-layout | CSS 2.1 §17.5.2.2}, From a70870e8085d88150a5a65faf1c5eada391a7143 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 19:28:40 +0200 Subject: [PATCH 13/21] fix(heuristic-table-plugin): fill table height, preserve row heights, and support border-spacing --- .../heuristic-table-plugin/src/HTMLTable.tsx | 11 +- .../heuristic-table-plugin/src/TableLayout.ts | 41 ++++++- .../src/TreeRenderer.tsx | 45 ++++++- .../src/__tests__/HTMLTable.test.tsx | 21 +++- .../src/__tests__/borderSpacing.test.tsx | 116 ++++++++++++++++++ .../src/__tests__/tableRendering.test.tsx | 29 ++++- .../src/helpers/createRenderTree.ts | 13 +- .../src/helpers/resolveBorderSpacing.ts | 59 +++++++++ .../src/helpers/tableStyles.ts | 2 +- 9 files changed, 318 insertions(+), 19 deletions(-) create mode 100644 packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx create mode 100644 packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index 4a831f9..bcab753 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -23,17 +23,23 @@ function Container({ availableWidth: number; }>) { const scroll = shouldScrollTable(tableWidth, availableWidth); + // Carry the wrapper's spare height through to the rows, including when + // horizontal overflow requires a ScrollView. Keep the content's height floor. return scroll ? React.createElement( ScrollView, { contentContainerStyle: { width: tableWidth }, - style: { width: availableWidth }, + style: { width: availableWidth, flexGrow: 1, flexShrink: 0 }, horizontal: true }, children ) - : React.createElement(View, { style: { width: tableWidth } }, children); + : React.createElement( + View, + { style: { width: tableWidth, flexGrow: 1, flexShrink: 0 } }, + children + ); } /** @@ -85,6 +91,7 @@ const HTMLTable = memo(function HTMLTable({ > {React.createElement(TreeRenderer, { node: layout.renderTree, + borderSpacing: layout.borderSpacing, config, cellStyles: layout.cellStyles, borderCollapse: layout.borderCollapse, diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 55ac4f1..8e7a4fe 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -1,4 +1,7 @@ import { sum } from 'ramda'; +import resolveBorderSpacing, { + BorderSpacing +} from './helpers/resolveBorderSpacing'; import type { CellContentBox } from './CellContentWidthContext'; import { ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; @@ -27,6 +30,7 @@ const DEFAULT_FORCE_STRETCH = true; export default class TableLayout { public readonly display: Display; + public readonly borderSpacing: BorderSpacing; public readonly columnWidths: number[]; public readonly totalWidth: number; public readonly borderCollapse: boolean; @@ -63,6 +67,7 @@ export default class TableLayout { constructor(tnode: TNode, config: Settings, cellContentBox?: CellContentBox) { const style = tnode.styles.nativeBlockRet; this.borderCollapse = resolveBorderCollapse(tnode, config.borderCollapse); + this.borderSpacing = resolveBorderSpacing(tnode, this.borderCollapse); const containingWidth = resolveAvailableWidth( tnode, config.contentWidth, @@ -107,6 +112,9 @@ export default class TableLayout { forceStretch }); fillTableDisplay(tnode, display); + const spacingWidth = display.cells.length + ? (display.maxX + 2) * this.borderSpacing.horizontal + : 0; const declaredColumnWidths = extractColumnWidths(tnode); const configStyles = new Map(); const measure = () => { @@ -120,20 +128,33 @@ export default class TableLayout { ...style, ...resolved.tableBorderStyle }); - display.contentWidth = Math.max(0, usedTableWidth - insets); + display.contentWidth = Math.max( + 0, + usedTableWidth - insets - spacingWidth + ); const computer = new TCellConstraintsComputer({ contentWidth: display.contentWidth, baseFontCoeff: config.baseFontCoeff, fontWeightCoeffs: config.fontWeightCoeffs }); for (const cell of display.cells) { - cell.constraints = computer.computeCellConstraints( + const constraints = computer.computeCellConstraints( cell.tnode, resolved.cellStyles.get(cell.tnode)!.style ); + // A spanning cell also occupies the gaps between its columns. + const internalSpacing = (cell.lenX - 1) * this.borderSpacing.horizontal; + cell.constraints = { + ...constraints, + minWidth: Math.max(0, constraints.minWidth - internalSpacing), + maxWidth: Math.max(0, constraints.maxWidth - internalSpacing) + }; } let columnWidths = computeColumnWidths(display, declaredColumnWidths); - const minLayoutWidth = Math.max(0, (minWidth ?? 0) - insets); + const minLayoutWidth = Math.max( + 0, + (minWidth ?? 0) - insets - spacingWidth + ); if (sum(columnWidths) < minLayoutWidth) { const raised = computeColumnWidths( { ...display, contentWidth: minLayoutWidth, forceStretch: true }, @@ -147,7 +168,11 @@ export default class TableLayout { if (config.getStyleForCell) { // Freeze callback results against provisional widths. Re-evaluating after // each resize could oscillate for a callback that branches on width. - for (const cell of makeTableCells(display, measured.columnWidths)) { + for (const cell of makeTableCells( + display, + measured.columnWidths, + this.borderSpacing.horizontal + )) { const configured = config.getStyleForCell.call(null, cell); configStyles.set(cell.tnode, configured ? { ...configured } : null); } @@ -161,8 +186,12 @@ export default class TableLayout { this.assignableWidth = Math.max(0, this.usedWidth - measured.insets); this.display = display; this.columnWidths = measured.columnWidths; - this.totalWidth = sum(this.columnWidths); - this.cells = makeTableCells(display, this.columnWidths); + this.totalWidth = sum(this.columnWidths) + spacingWidth; + this.cells = makeTableCells( + display, + this.columnWidths, + this.borderSpacing.horizontal + ); this.renderTree = createRenderTree(this.cells); } } diff --git a/packages/heuristic-table-plugin/src/TreeRenderer.tsx b/packages/heuristic-table-plugin/src/TreeRenderer.tsx index c4d9b9f..33b4943 100644 --- a/packages/heuristic-table-plugin/src/TreeRenderer.tsx +++ b/packages/heuristic-table-plugin/src/TreeRenderer.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { BorderSpacing } from './helpers/resolveBorderSpacing'; import { StyleSheet, View, ViewStyle } from 'react-native'; import { TNode, TNodeRenderer } from '@native-html/render'; import { ResolvedCellStyle } from './helpers/resolveTableStyles'; @@ -13,6 +14,7 @@ const styles = StyleSheet.create({ export default function TreeRenderer({ node, + borderSpacing = { horizontal: 0, vertical: 0 }, config, cellStyles, borderCollapse, @@ -23,6 +25,7 @@ export default function TreeRenderer({ renderLength }: { node: TableRenderNode; + borderSpacing?: BorderSpacing; renderIndex: number; renderLength: number; config?: HeuristicTablePluginConfig; @@ -48,7 +51,13 @@ export default function TreeRenderer({ ); if (node.type === 'cell') { return ( - + {children}; + return ( + 0 && { + paddingHorizontal: borderSpacing.horizontal, + paddingVertical: borderSpacing.vertical + } + ]} + > + {children} + + ); } if (node.type === 'row-container') { + // The render tree replaces source rows with flex containers. Preserve + // their height floor here; a row must still grow when its content is taller. + // A spanning cell does not impose its starting row's height on its whole + // synthetic row group. + const minHeight = node.children.reduce((height, child) => { + if (child.type !== 'cell' || child.lenY !== 1) return height; + const row = child.tnode.parent; + if (row?.tagName !== 'tr') return height; + const style = row.styles.nativeBlockRet; + return Math.max( + height, + typeof style.height === 'number' ? style.height : 0, + typeof style.minHeight === 'number' ? style.minHeight : 0 + ); + }, 0); return ( - + 0 && { minHeight }]}> {node.children.map((v, i) => React.createElement(TreeRenderer, { node: v, key: i, config, + borderSpacing, cellStyles, borderCollapse, tableBorderStyle, diff --git a/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx b/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx index 416aed6..8ab5a6d 100644 --- a/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx +++ b/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx @@ -65,6 +65,23 @@ describe('HTMLTable containers', () => { ); }); + it.each([100, 600])( + 'lets the inner container fill the table height with %spx content', + (width) => { + const rendered = renderTable( + `
A
`, + 400 + ); + const wrapper = rendered.getByTestId('table-wrapper'); + const scroll = rendered.UNSAFE_queryByType(ScrollView); + const container = scroll ?? wrapper.findAllByType(View)[0]; + expect(StyleSheet.flatten(container.props.style)).toMatchObject({ + flexGrow: 1, + flexShrink: 0 + }); + } + ); + it('uses the capped table width for the wrapper and overflow viewport', () => { const rendered = renderTable( '
AB
', @@ -73,7 +90,7 @@ describe('HTMLTable containers', () => { expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ width: 300 }); const scroll = rendered.UNSAFE_getByType(ScrollView); expect(scroll.props.horizontal).toBe(true); - expect(scroll.props.style).toEqual({ width: 300 }); + expect(scroll.props.style).toMatchObject({ width: 300 }); expect(scroll.props.contentContainerStyle).toEqual({ width: 600 }); }); @@ -91,7 +108,7 @@ describe('HTMLTable containers', () => { (html, width) => { const rendered = renderTable(html, 400); expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ width }); - expect(rendered.UNSAFE_getByType(ScrollView).props.style).toEqual({ + expect(rendered.UNSAFE_getByType(ScrollView).props.style).toMatchObject({ width: 0 }); } diff --git a/packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx b/packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx new file mode 100644 index 0000000..ac4b105 --- /dev/null +++ b/packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { StyleSheet, View } from 'react-native'; +import RenderHTML from '@native-html/render'; +import renderers from '../index'; +import TableLayout from '../TableLayout'; +import resolveBorderSpacing from '../helpers/resolveBorderSpacing'; +import { createTableTNode } from './utils'; + +describe('border spacing', () => { + it.each([ + ['border-spacing:8px 12px', 8, 12], + ['border-spacing:5px', 5, 5], + ['border-spacing:0', 0, 0], + ['border-spacing:-1px', 0, 0], + ['border-spacing:10%', 0, 0], + ['border-spacing:1px 2px 3px', 0, 0], + ['font-size:20px;border-spacing:0.5em 1em', 10, 20] + ])('resolves %s', (style, horizontal, vertical) => { + const table = createTableTNode( + `
A
` + ); + expect(resolveBorderSpacing(table, false)).toEqual({ + horizontal, + vertical + }); + expect(resolveBorderSpacing(table, true)).toEqual({ + horizontal: 0, + vertical: 0 + }); + }); + + it('supports inherited spacing and the legacy cellspacing attribute', () => { + const inherited = createTableTNode( + '
A
' + ); + expect(resolveBorderSpacing(inherited, false)).toEqual({ + horizontal: 8, + vertical: 12 + }); + const legacy = createTableTNode( + '
A
' + ); + expect(resolveBorderSpacing(legacy, false)).toEqual({ + horizontal: 6, + vertical: 6 + }); + const override = createTableTNode( + '
A
' + ); + expect(resolveBorderSpacing(override, false)).toEqual({ + horizontal: 0, + vertical: 0 + }); + }); + + it('reserves gaps inside the table width and includes internal gaps in colspan widths', () => { + const table = createTableTNode( + '
AB
C
' + ); + const layout = new TableLayout(table, { contentWidth: 300 }); + expect(layout.totalWidth).toBeCloseTo(300); + expect(layout.columnWidths.reduce((a, b) => a + b, 0)).toBeCloseTo(276); + expect(layout.cells[2].width).toBeCloseTo(284); + const collapsed = new TableLayout(table, { + contentWidth: 300, + borderCollapse: 'collapse' + }); + expect(collapsed.cells[2].width).toBeCloseTo(300); + }); + + it('includes spacing when content overflows', () => { + const table = createTableTNode( + '
AB
' + ); + const layout = new TableLayout(table, { contentWidth: 300 }); + expect(layout.totalWidth).toBe(424); + expect(layout.assignableWidth).toBe(300); + }); + + it('paints gaps outside cells and only once around the grid', () => { + const rendered = render( + ABC' + }} + /> + ); + const styles = rendered + .UNSAFE_getAllByType(View) + .map((view) => StyleSheet.flatten(view.props.style)); + expect( + styles.filter( + (style) => + style?.paddingHorizontal === 8 && style?.paddingVertical === 12 + ) + ).toHaveLength(1); + expect( + styles.filter( + (style) => style?.marginEnd === 8 && style?.marginBottom === 12 + ) + ).toHaveLength(1); + expect( + styles.filter( + (style) => style?.marginEnd === 0 && style?.marginBottom === 12 + ) + ).toHaveLength(1); + expect( + styles.filter( + (style) => style?.marginEnd === 0 && style?.marginBottom === 0 + ) + ).toHaveLength(1); + }); +}); diff --git a/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx b/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx index 38daeb1..03354e6 100644 --- a/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx +++ b/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -import { StyleSheet, ViewStyle } from 'react-native'; +import { StyleSheet, View, ViewStyle } from 'react-native'; import RenderHTML, { CustomBlockRenderer } from '@native-html/render'; import renderers from '../index'; import { TableCell } from '../shared-types'; @@ -250,3 +250,30 @@ describe('measured styles across rerenders', () => { expect(first).toHaveBeenCalledTimes(2); }); }); + +describe('row height constraints', () => { + it.each([ + ['height:90px', 90], + ['height:90px;min-height:120px', 120], + ['min-height:80px', 80] + ])('preserves %s on the native row', (rowStyle, minHeight) => { + const rendered = render( + AB` + }} + renderers={renderers} + /> + ); + const rows = rendered.UNSAFE_getAllByType(View).filter( + (view) => StyleSheet.flatten(view.props.style)?.flexDirection === 'row' + ); + expect(rows).toHaveLength(1); + expect(StyleSheet.flatten(rows[0].props.style)).toMatchObject({ + minHeight, + flexGrow: 1 + }); + expect(StyleSheet.flatten(rows[0].props.style).height).toBeUndefined(); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts b/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts index 43d82aa..24c6f31 100644 --- a/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts +++ b/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts @@ -87,7 +87,11 @@ function translateVGroups( return flattenRows; } -function makeCell(columnWidths: number[], cell: DisplayCell): TableCell { +function makeCell( + columnWidths: number[], + cell: DisplayCell, + spacing: number +): TableCell { let width = 0; for (let i = cell.x; i < cell.x + cell.lenX; i++) { width += columnWidths[i] ?? 0; @@ -95,7 +99,7 @@ function makeCell(columnWidths: number[], cell: DisplayCell): TableCell { return { ...cell, type: 'cell', - width + width: width + Math.max(0, cell.lenX - 1) * spacing }; } @@ -109,9 +113,10 @@ function makeCell(columnWidths: number[], cell: DisplayCell): TableCell { */ export function makeTableCells( display: Pick, - columnWidths: number[] + columnWidths: number[], + spacing = 0 ): TableCell[] { - return display.cells.map((cell) => makeCell(columnWidths, cell)); + return display.cells.map((cell) => makeCell(columnWidths, cell, spacing)); } export default function createRenderTree(cells: TableCell[]): TableRoot { diff --git a/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts b/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts new file mode 100644 index 0000000..b2e17e1 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts @@ -0,0 +1,59 @@ +import { TNode } from '@native-html/render'; +import { getInlineStyleValue } from './tableStyles'; +import { resolveAttributeLength } from './resolveWidth'; + +export interface BorderSpacing { + horizontal: number; + vertical: number; +} + +const ZERO: BorderSpacing = { horizontal: 0, vertical: 0 }; + +function parseSpacing(value: string, node: TNode): BorderSpacing | null { + const parts = value.split(/\s+/); + if (parts.length < 1 || parts.length > 2) return null; + const lengths = parts.map((part) => { + const match = /^(\d*\.?\d+)(px|em|rem|pt|pc|in|cm|mm)?$/.exec(part); + if (!match) return NaN; + const number = Number(match[1]); + const unit = match[2]; + if (!unit && number !== 0) return NaN; + let root = node; + while (root.parent) root = root.parent; + const scales: Record = { + px: 1, + em: node.styles.nativeTextFlow.fontSize ?? 16, + rem: root.styles.nativeTextFlow.fontSize ?? 16, + pt: 96 / 72, + pc: 16, + in: 96, + cm: 96 / 2.54, + mm: 96 / 25.4 + }; + return number * (unit ? scales[unit] : 1); + }); + if (lengths.some((length) => !Number.isFinite(length))) return null; + return { horizontal: lengths[0], vertical: lengths[1] ?? lengths[0] }; +} + +/** Unsupported web-only CSS survives on the source attributes, not native styles. */ +export default function resolveBorderSpacing( + tnode: TNode, + collapse: boolean +): BorderSpacing { + if (collapse) return ZERO; + for (let node: TNode | null = tnode; node; node = node.parent) { + const value = getInlineStyleValue(node, 'border-spacing'); + if (value === 'initial') return ZERO; + if (value) { + const spacing = parseSpacing(value, node); + if (spacing) return spacing; + if (value === 'inherit' || value === 'unset') continue; + } + if (node.tagName === 'table') { + const spacing = resolveAttributeLength(node.attributes.cellspacing); + if (spacing !== null) return { horizontal: spacing, vertical: spacing }; + } + } + return ZERO; +} diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index 03da492..19e03f4 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -6,7 +6,7 @@ export type BorderCollapse = 'collapse' | 'separate'; export type CellVerticalAlign = 'baseline' | 'bottom' | 'middle' | 'top'; -function getInlineStyleValue( +export function getInlineStyleValue( tnode: TNode, propertyName: string ): string | null { From 2bdeca0e8544659e88b07f4ac154da682152fad9 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 19:47:24 +0200 Subject: [PATCH 14/21] fix(heuristic-table-plugin): scroll fixed-height tables without clipping cell content --- ...istictablepluginconfig.growbeyondheight.md | 4 +-- ...table-plugin.heuristictablepluginconfig.md | 2 +- packages/heuristic-table-plugin/package.json | 2 +- .../heuristic-table-plugin/src/HTMLTable.tsx | 25 ++++++++++++++---- .../src/__tests__/HTMLTable.test.tsx | 26 ++++++++++++++++++- .../__tests__/useHtmlTableCellProps.test.ts | 20 +++++++++++--- .../src/shared-types.ts | 13 +++++----- .../src/useHtmlTableCellProps.ts | 10 +++---- .../heuristic-table-plugin/tsconfig.test.json | 8 ++++++ 9 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 packages/heuristic-table-plugin/tsconfig.test.json diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md index f8f6160..8c5b3e7 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md @@ -4,7 +4,7 @@ ## HeuristicTablePluginConfig.growBeyondHeight property -When true, an explicit `height` on the table, or on any of its cells, is treated as a minimum: the box still grows to fit content taller than it. When false, that `height` is enforced as written and taller content overflows it. +When true, an explicit table `height` is treated as a minimum and the table grows to fit taller content. When false, the table keeps that height and scrolls vertically. Rows and cells always grow to fit their content; their declared heights are minimums in either mode. **Signature:** @@ -14,5 +14,5 @@ growBeyondHeight?: boolean; ## Remarks -Per [CSS 2.1 §17.5.3](https://www.w3.org/TR/CSS21/tables.html#height-layout), `height` on a `table`, `tr`, `th` or `td` box is only a minimum, so `true` is the faithful reading of the HTML. It is off by default because React Native has no table layout algorithm to shrink a row back down, and a document whose markup sizes its tables is better served by a box that stays the size it asked for. +Per [CSS 2.1 §17.5.3](https://www.w3.org/TR/CSS21/tables.html#height-layout), `height` on a `table`, `tr`, `th` or `td` box is only a minimum, so `true` is the faithful reading of the HTML. It is off by default to preserve the requested table viewport size while keeping all content accessible by scrolling. diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md index 7d6406c..59e4443 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.heuristictablepluginconfig.md @@ -126,7 +126,7 @@ boolean -_(Optional)_ When true, an explicit `height` on the table, or on any of its cells, is treated as a minimum: the box still grows to fit content taller than it. When false, that `height` is enforced as written and taller content overflows it. +_(Optional)_ When true, an explicit table `height` is treated as a minimum and the table grows to fit taller content. When false, the table keeps that height and scrolls vertically. Rows and cells always grow to fit their content; their declared heights are minimums in either mode. diff --git a/packages/heuristic-table-plugin/package.json b/packages/heuristic-table-plugin/package.json index 0f88d12..7a14892 100644 --- a/packages/heuristic-table-plugin/package.json +++ b/packages/heuristic-table-plugin/package.json @@ -15,7 +15,7 @@ "scripts": { "test": "yarn test:ts && yarn test:lint && yarn test:jest", "test:jest": "jest src/", - "test:ts": "tsc --noEmit", + "test:ts": "tsc --noEmit && tsc -p tsconfig.test.json", "test:lint": "eslint src/", "build": "yarn build:source && yarn build:defs && yarn build:doc", "build:source": "bob build", diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index bcab753..b9e713e 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -17,15 +17,17 @@ export function shouldScrollTable( function Container({ children, tableWidth, - availableWidth + availableWidth, + scrollVertically }: PropsWithChildren<{ tableWidth: number; availableWidth: number; + scrollVertically: boolean; }>) { const scroll = shouldScrollTable(tableWidth, availableWidth); // Carry the wrapper's spare height through to the rows, including when // horizontal overflow requires a ScrollView. Keep the content's height floor. - return scroll + const content = scroll ? React.createElement( ScrollView, { @@ -40,6 +42,15 @@ function Container({ { style: { width: tableWidth, flexGrow: 1, flexShrink: 0 } }, children ); + // Measure rows without the viewport's height constraint. Keep the vertical + // scroller outside the horizontal one so both axes can overflow independently. + return scrollVertically ? ( + + {content} + + ) : ( + content + ); } /** @@ -67,9 +78,8 @@ const HTMLTable = memo(function HTMLTable({ {React.createElement(TreeRenderer, { node: layout.renderTree, diff --git a/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx b/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx index 8ab5a6d..c8ef26e 100644 --- a/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx +++ b/packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx @@ -50,6 +50,9 @@ describe('HTMLTable containers', () => { expect(StyleSheet.flatten(wrapper.props.style)).not.toHaveProperty( 'minHeight' ); + expect(rendered.UNSAFE_getByType(ScrollView).props.horizontal).not.toBe( + true + ); }); it('passes an explicit table height as minHeight when growBeyondHeight is set', () => { @@ -63,6 +66,7 @@ describe('HTMLTable containers', () => { expect(StyleSheet.flatten(wrapper.props.style)).not.toHaveProperty( 'height' ); + expect(rendered.UNSAFE_queryByType(ScrollView)).toBeNull(); }); it.each([100, 600])( @@ -70,7 +74,8 @@ describe('HTMLTable containers', () => { (width) => { const rendered = renderTable( `
A
`, - 400 + 400, + { growBeyondHeight: true } ); const wrapper = rendered.getByTestId('table-wrapper'); const scroll = rendered.UNSAFE_queryByType(ScrollView); @@ -82,6 +87,25 @@ describe('HTMLTable containers', () => { } ); + it.each([100, 600])( + 'keeps natural %spx-wide content inside a bounded vertical viewport', + (width) => { + const rendered = renderTable( + `
A
`, + 400, + { growBeyondHeight: false } + ); + const scrollers = rendered.UNSAFE_getAllByType(ScrollView); + const vertical = scrollers.find((view) => !view.props.horizontal)!; + expect(vertical.props.style).toMatchObject({ flexShrink: 1 }); + expect(vertical.props.contentContainerStyle).toBeUndefined(); + expect(scrollers.filter((view) => view.props.horizontal)).toHaveLength( + width > 400 ? 1 : 0 + ); + expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ height: 48 }); + } + ); + it('uses the capped table width for the wrapper and overflow viewport', () => { const rendered = renderTable( '
AB
', diff --git a/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts b/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts index 2cf3ffb..bd2e693 100644 --- a/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts @@ -70,10 +70,22 @@ function cellStyleFor( } describe('useHtmlTableCellProps', () => { - it.each(['td', 'th'])('enforces an explicit %s height by default', (tag) => { - const style = cellStyleFor(`<${tag} style="height:48px">A`); - expect(style.height).toBe(48); - expect(style).not.toHaveProperty('minHeight'); + it.each(['td', 'th'])( + 'uses an explicit %s height as a minimum by default', + (tag) => { + const style = cellStyleFor(`<${tag} style="height:48px">A`); + expect(style.minHeight).toBe(48); + expect(style).not.toHaveProperty('height'); + } + ); + + it('allows content to outgrow a configured cell height', () => { + const style = cellStyleFor('Wrapping content', { + growBeyondHeight: false, + getStyleForCell: () => ({ height: 24 }) + }); + expect(style.minHeight).toBe(24); + expect(style).not.toHaveProperty('height'); }); it.each(['td', 'th'])( diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 17bc526..d81c155 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -263,18 +263,17 @@ export interface HeuristicTablePluginConfig { */ borderCollapse?: 'collapse' | 'separate'; /** - * When true, an explicit `height` on the table, or on any of its cells, is - * treated as a minimum: the box still grows to fit content taller than it. - * When false, that `height` is enforced as written and taller content - * overflows it. + * When true, an explicit table `height` is treated as a minimum and the + * table grows to fit taller content. When false, the table keeps that height + * and scrolls vertically. Rows and cells always grow to fit their content; + * their declared heights are minimums in either mode. * * @remarks * Per {@link https://www.w3.org/TR/CSS21/tables.html#height-layout | CSS 2.1 * §17.5.3}, `height` on a `table`, `tr`, `th` or `td` box is only a minimum, * so `true` is the faithful reading of the HTML. It is off by default - * because React Native has no table layout algorithm to shrink a row back - * down, and a document whose markup sizes its tables is better served by a - * box that stays the size it asked for. + * to preserve the requested table viewport size while keeping all content + * accessible by scrolling. * * @defaultValue false */ diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index 852ac10..3796077 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -96,16 +96,12 @@ export default function useHtmlTableCellProps({ // The user-agent stylesheet is the weakest declaration of the three, and // only covers the sides no author declaration reached. ...defaultPaddingStyle, - // An explicit height on a cell is a minimum height in HTML, but only - // `growBeyondHeight` opts into letting the cell grow past it; by default - // the declared height is enforced as written. - ...(config?.growBeyondHeight - ? relaxHeightConstraint(props.style) - : props.style), + // Cells must fit their content even inside a fixed-height table viewport. + ...relaxHeightConstraint(props.style), flexGrow: 1, flexShrink: 0, ...alignmentStyles, - ...styleFromConfig, + ...relaxHeightConstraint(styleFromConfig ?? {}), ...collapsedBorderStyle, width: cell.width, marginLeft: 0, diff --git a/packages/heuristic-table-plugin/tsconfig.test.json b/packages/heuristic-table-plugin/tsconfig.test.json new file mode 100644 index 0000000..ff5e938 --- /dev/null +++ b/packages/heuristic-table-plugin/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig-base.json", + "compilerOptions": { + "types": ["jest", "node", "@testing-library/react-native"], + "noEmit": true + }, + "include": ["src/**/__tests__/**/*.ts", "src/**/__tests__/**/*.tsx"] +} From 322073e27dd1bb77dec828969b74490a60303677 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 20:25:05 +0200 Subject: [PATCH 15/21] fix(heuristic-table-plugin): stop requiring contentWidth in config and re-applying width bounds --- .../heuristic-table-plugin.htmltableprops.md | 2 +- .../docs/heuristic-table-plugin.md | 11 + ...tic-table-plugin.settings.basefontcoeff.md | 18 ++ ...ic-table-plugin.settings.bordercollapse.md | 13 + ...stic-table-plugin.settings.contentwidth.md | 18 ++ ...-table-plugin.settings.fontweightcoeffs.md | 18 ++ ...stic-table-plugin.settings.forcestretch.md | 13 + ...c-table-plugin.settings.getstyleforcell.md | 11 + .../docs/heuristic-table-plugin.settings.md | 157 ++++++++++ .../etc/heuristic-table-plugin.api.md | 293 +++++++++--------- .../heuristic-table-plugin/src/HTMLTable.tsx | 11 +- .../src/__tests__/HTMLTable.test.tsx | 26 +- .../src/helpers/__tests__/makeRows.test.ts | 3 +- packages/heuristic-table-plugin/src/index.ts | 5 +- .../src/shared-types.ts | 15 +- 15 files changed, 464 insertions(+), 150 deletions(-) create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.basefontcoeff.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.bordercollapse.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.contentwidth.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.fontweightcoeffs.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.forcestretch.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.getstyleforcell.md create mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.md index e9a1cfe..97c939e 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.md @@ -80,7 +80,7 @@ TableLayout -Settings +[Settings](./heuristic-table-plugin.settings.md) diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md index 21d154e..d94d56d 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md @@ -105,6 +105,17 @@ Options to customize this plugin renderers. Props for the [HTMLTable](./heuristic-table-plugin.htmltable.md) component. + + + +[Settings](./heuristic-table-plugin.settings.md) + + + + +Everything the table layout engine needs to lay a table out: the author configuration, plus the width the document offers it. + + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.basefontcoeff.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.basefontcoeff.md new file mode 100644 index 0000000..5047e57 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.basefontcoeff.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [baseFontCoeff](./heuristic-table-plugin.settings.basefontcoeff.md) + +## Settings.baseFontCoeff property + +The average advance width of one character, as a fraction of the font size, used to estimate how wide a cell's text is. + +**Signature:** + +```typescript +baseFontCoeff?: number; +``` + +## Remarks + +Text is never measured, only estimated: a cell's bounds are its character count times this coefficient times the font size. Raise it when tables come out too narrow and their text wraps more than it should, lower it when cells claim more width than their content occupies. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.bordercollapse.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.bordercollapse.md new file mode 100644 index 0000000..3eae822 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.bordercollapse.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [borderCollapse](./heuristic-table-plugin.settings.bordercollapse.md) + +## Settings.borderCollapse property + +Override the table's `border-collapse` mode. + +**Signature:** + +```typescript +borderCollapse?: 'collapse' | 'separate'; +``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.contentwidth.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.contentwidth.md new file mode 100644 index 0000000..8958b60 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.contentwidth.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [contentWidth](./heuristic-table-plugin.settings.contentwidth.md) + +## Settings.contentWidth property + +Available width at the root of the render tree, prior to scrolling. + +**Signature:** + +```typescript +contentWidth: number; +``` + +## Remarks + +This is the width offered to the document as a whole. The horizontal spacing of the table's ancestors, and of the table itself, is subtracted from it by the table layout engine. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.fontweightcoeffs.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.fontweightcoeffs.md new file mode 100644 index 0000000..503fce9 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.fontweightcoeffs.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [fontWeightCoeffs](./heuristic-table-plugin.settings.fontweightcoeffs.md) + +## Settings.fontWeightCoeffs property + +How much wider text renders at a given font weight than at a regular one, keyed by the stringified `fontWeight`. + +**Signature:** + +```typescript +fontWeightCoeffs?: FontWeightCoefficients; +``` + +## Remarks + +Merged over the defaults rather than replacing them, so `{ bold: 1.05 }` retunes bold text alone and leaves the numeric weights as they were. A weight with no entry, before or after merging, costs nothing. Pass a referentially stable object — a fresh literal on every render relays out every table using it. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.forcestretch.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.forcestretch.md new file mode 100644 index 0000000..dfa3e58 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.forcestretch.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [forceStretch](./heuristic-table-plugin.settings.forcestretch.md) + +## Settings.forceStretch property + +When true, force the table to stretch to the available width. + +**Signature:** + +```typescript +forceStretch?: boolean; +``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.getstyleforcell.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.getstyleforcell.md new file mode 100644 index 0000000..be34ac9 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.getstyleforcell.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [getStyleForCell](./heuristic-table-plugin.settings.getstyleforcell.md) + +## Settings.getStyleForCell property + +**Signature:** + +```typescript +getStyleForCell?: HeuristicTablePluginConfig['getStyleForCell']; +``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md new file mode 100644 index 0000000..a2434e7 --- /dev/null +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md @@ -0,0 +1,157 @@ + + +[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) + +## Settings interface + +Everything the table layout engine needs to lay a table out: the author configuration, plus the width the document offers it. + +**Signature:** + +```typescript +export interface Settings +``` + +## Remarks + +This is resolved by [useHtmlTableProps()](./heuristic-table-plugin.usehtmltableprops.md) and handed to [HTMLTable](./heuristic-table-plugin.htmltable.md); it is not the shape a consumer writes. Author configuration goes to `renderersProps.table` as a [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md), which carries no [Settings.contentWidth](./heuristic-table-plugin.settings.contentwidth.md). + +## Properties + + + + + + + + +
+ +Property + + + + +Modifiers + + + + +Type + + + + +Description + + +
+ +[baseFontCoeff?](./heuristic-table-plugin.settings.basefontcoeff.md) + + + + + + + +number + + + + +_(Optional)_ The average advance width of one character, as a fraction of the font size, used to estimate how wide a cell's text is. + + +
+ +[borderCollapse?](./heuristic-table-plugin.settings.bordercollapse.md) + + + + + + + +'collapse' \| 'separate' + + + + +_(Optional)_ Override the table's `border-collapse` mode. + + +
+ +[contentWidth](./heuristic-table-plugin.settings.contentwidth.md) + + + + + + + +number + + + + +Available width at the root of the render tree, prior to scrolling. + + +
+ +[fontWeightCoeffs?](./heuristic-table-plugin.settings.fontweightcoeffs.md) + + + + + + + +[FontWeightCoefficients](./heuristic-table-plugin.fontweightcoefficients.md) + + + + +_(Optional)_ How much wider text renders at a given font weight than at a regular one, keyed by the stringified `fontWeight`. + + +
+ +[forceStretch?](./heuristic-table-plugin.settings.forcestretch.md) + + + + + + + +boolean + + + + +_(Optional)_ When true, force the table to stretch to the available width. + + +
+ +[getStyleForCell?](./heuristic-table-plugin.settings.getstyleforcell.md) + + + + + + + +[HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md)\['getStyleForCell'\] + + + + +_(Optional)_ + + +
+ diff --git a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md index 92579f0..a8dc6e4 100644 --- a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md +++ b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md @@ -1,142 +1,151 @@ -## API Report File for "@native-html/heuristic-table-plugin" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { CustomBlockRenderer } from '@native-html/render'; -import { CustomRendererProps } from '@native-html/render'; -import { HTMLContentModel } from '@native-html/render'; -import { HTMLElementModel } from '@native-html/render'; -import { PropsFromParent } from '@native-html/render'; -import { default as React_2 } from 'react'; -import { TBlock } from '@native-html/render'; -import { TNode } from '@native-html/render'; -import { ViewStyle } from 'react-native'; - -// @public (undocumented) -export interface CellProperties extends Coordinates { - // Warning: (ae-forgotten-export) The symbol "TCellConstraints" needs to be exported by the entry point index.d.ts - // - // (undocumented) - constraints: TCellConstraints; - // (undocumented) - lenX: number; - // (undocumented) - lenY: number; -} - -// @public -export const colgroupModel: HTMLElementModel<'colgroup', HTMLContentModel.block>; - -// @public (undocumented) -export interface Coordinates { - // (undocumented) - x: number; - // (undocumented) - y: number; -} - -// @public -export const DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients; - -// @public (undocumented) -export interface DisplayCell extends CellProperties { - // (undocumented) - tnode: TNode; -} - -// @public -export type FontWeightCoefficients = Record; - -// @public -export interface HeuristicTablePluginConfig { - baseFontCoeff?: number; - borderCollapse?: 'collapse' | 'separate'; - fontWeightCoeffs?: FontWeightCoefficients; - forceStretch?: boolean; - getStyleForCell?(cell: TableCell): ViewStyle | null; - growBeyondHeight?: boolean; -} - -// @public -export const HTMLTable: React_2.NamedExoticComponent; - -// @public -export interface HTMLTableProps extends CustomRendererProps { - // (undocumented) - config: HeuristicTablePluginConfig; - // Warning: (ae-forgotten-export) The symbol "TableLayout" needs to be exported by the entry point index.d.ts - // - // (undocumented) - layout: TableLayout; - // Warning: (ae-forgotten-export) The symbol "Settings" needs to be exported by the entry point index.d.ts - // - // (undocumented) - settings: Settings; -} - -// @public -const renderers: Record<'th' | 'td' | 'table', CustomBlockRenderer>; -export default renderers; - -// @public -export interface TableCell extends DisplayCell { - // (undocumented) - type: 'cell'; - // (undocumented) - width: number; -} - -// @public -export interface TableCellPropsFromParent extends PropsFromParent { - // (undocumented) - cell: TableCell; - // (undocumented) - config?: HeuristicTablePluginConfig; -} - -// @public -export interface TableFlexColumnContainer { - // (undocumented) - children: (TableFlexRowContainer | TableCell)[]; - // (undocumented) - type: 'col-container'; -} - -// @public -export interface TableFlexRowContainer { - // (undocumented) - children: (TableFlexColumnContainer | TableCell)[]; - // (undocumented) - type: 'row-container'; -} - -// @public -export const TableRenderer: CustomBlockRenderer; - -// @public (undocumented) -export interface TableRoot { - // (undocumented) - children: TableFlexRowContainer[]; - // (undocumented) - type: 'root'; -} - -// @public -export const TdRenderer: CustomBlockRenderer; - -// @public -export const ThRenderer: CustomBlockRenderer; - -// @public -export function useHtmlTableCellProps(input: CustomRendererProps): CustomRendererProps; - -// @public -export function useHtmlTableProps(input: CustomRendererProps, options?: { - overrideContentWidth?: number; -}): HTMLTableProps; - -// (No @packageDocumentation comment for this package) - -``` +## API Report File for "@native-html/heuristic-table-plugin" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { CustomBlockRenderer } from '@native-html/render'; +import { CustomRendererProps } from '@native-html/render'; +import { HTMLContentModel } from '@native-html/render'; +import { HTMLElementModel } from '@native-html/render'; +import { PropsFromParent } from '@native-html/render'; +import { default as React_2 } from 'react'; +import { TBlock } from '@native-html/render'; +import { TNode } from '@native-html/render'; +import { ViewStyle } from 'react-native'; + +// @public (undocumented) +export interface CellProperties extends Coordinates { + // Warning: (ae-forgotten-export) The symbol "TCellConstraints" needs to be exported by the entry point index.d.ts + // + // (undocumented) + constraints: TCellConstraints; + // (undocumented) + lenX: number; + // (undocumented) + lenY: number; +} + +// @public +export const colgroupModel: HTMLElementModel<'colgroup', HTMLContentModel.block>; + +// @public (undocumented) +export interface Coordinates { + // (undocumented) + x: number; + // (undocumented) + y: number; +} + +// @public +export const DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients; + +// @public (undocumented) +export interface DisplayCell extends CellProperties { + // (undocumented) + tnode: TNode; +} + +// @public +export type FontWeightCoefficients = Record; + +// @public +export interface HeuristicTablePluginConfig { + baseFontCoeff?: number; + borderCollapse?: 'collapse' | 'separate'; + fontWeightCoeffs?: FontWeightCoefficients; + forceStretch?: boolean; + getStyleForCell?(cell: TableCell): ViewStyle | null; + growBeyondHeight?: boolean; +} + +// @public +export const HTMLTable: React_2.NamedExoticComponent; + +// @public +export interface HTMLTableProps extends CustomRendererProps { + // (undocumented) + config: HeuristicTablePluginConfig; + // Warning: (ae-forgotten-export) The symbol "TableLayout" needs to be exported by the entry point index.d.ts + // + // (undocumented) + layout: TableLayout; + // (undocumented) + settings: Settings; +} + +// @public +const renderers: Record<'th' | 'td' | 'table', CustomBlockRenderer>; +export default renderers; + +// @public +export interface Settings { + baseFontCoeff?: number; + borderCollapse?: 'collapse' | 'separate'; + contentWidth: number; + fontWeightCoeffs?: FontWeightCoefficients; + forceStretch?: boolean; + // (undocumented) + getStyleForCell?: HeuristicTablePluginConfig['getStyleForCell']; +} + +// @public +export interface TableCell extends DisplayCell { + // (undocumented) + type: 'cell'; + // (undocumented) + width: number; +} + +// @public +export interface TableCellPropsFromParent extends PropsFromParent { + // (undocumented) + cell: TableCell; + // (undocumented) + config?: HeuristicTablePluginConfig; +} + +// @public +export interface TableFlexColumnContainer { + // (undocumented) + children: (TableFlexRowContainer | TableCell)[]; + // (undocumented) + type: 'col-container'; +} + +// @public +export interface TableFlexRowContainer { + // (undocumented) + children: (TableFlexColumnContainer | TableCell)[]; + // (undocumented) + type: 'row-container'; +} + +// @public +export const TableRenderer: CustomBlockRenderer; + +// @public (undocumented) +export interface TableRoot { + // (undocumented) + children: TableFlexRowContainer[]; + // (undocumented) + type: 'root'; +} + +// @public +export const TdRenderer: CustomBlockRenderer; + +// @public +export const ThRenderer: CustomBlockRenderer; + +// @public +export function useHtmlTableCellProps(input: CustomRendererProps): CustomRendererProps; + +// @public +export function useHtmlTableProps(input: CustomRendererProps, options?: { + overrideContentWidth?: number; +}): HTMLTableProps; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index b9e713e..2a002ba 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -45,7 +45,10 @@ function Container({ // Measure rows without the viewport's height constraint. Keep the vertical // scroller outside the horizontal one so both axes can overflow independently. return scrollVertically ? ( - + {content} ) : ( @@ -92,7 +95,11 @@ const HTMLTable = memo(function HTMLTable({ // whichever of the two comes first and the overflow goes to the // scroller inside. A table narrower than that keeps its own size, // insets included. - width: Math.min(tableWidth + insets, layout.usedWidth) + width: Math.min(tableWidth + insets, layout.usedWidth), + // Layout already applied these bounds to the table content. Reapplying + // them in Yoga would override the capped viewport width above. + minWidth: undefined, + maxWidth: undefined }} > { const scrollers = rendered.UNSAFE_getAllByType(ScrollView); const vertical = scrollers.find((view) => !view.props.horizontal)!; expect(vertical.props.style).toMatchObject({ flexShrink: 1 }); - expect(vertical.props.contentContainerStyle).toBeUndefined(); + expect(vertical.props.contentContainerStyle).toEqual({ flexGrow: 1 }); expect(scrollers.filter((view) => view.props.horizontal)).toHaveLength( width > 400 ? 1 : 0 ); @@ -106,6 +106,30 @@ describe('HTMLTable containers', () => { } ); + it.each([ + 'min-width:600px', + 'min-width:200%', + 'min-width:600px;max-width:200px' + ])( + 'keeps %s on the content without widening or narrowing the viewport', + (style) => { + const rendered = renderTable( + `
A
`, + 300 + ); + const wrapperStyle = StyleSheet.flatten( + rendered.getByTestId('table-wrapper').props.style + ); + expect(wrapperStyle.width).toBe(300); + expect(wrapperStyle.minWidth).toBeUndefined(); + expect(wrapperStyle.maxWidth).toBeUndefined(); + const scroll = rendered.UNSAFE_getByType(ScrollView); + expect(scroll.props.horizontal).toBe(true); + expect(scroll.props.style).toMatchObject({ width: 300 }); + expect(scroll.props.contentContainerStyle).toEqual({ width: 600 }); + } + ); + it('uses the capped table width for the wrapper and overflow viewport', () => { const rendered = renderTable( '
AB
', diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts index 5029346..39b76c3 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts @@ -11,7 +11,8 @@ function cell(y: number, x: number = 0): TableCell { type: 'cell', constraints: { contentDensity: 0, - minWidth: 0 + minWidth: 0, + maxWidth: 0 }, width: 10, x, diff --git a/packages/heuristic-table-plugin/src/index.ts b/packages/heuristic-table-plugin/src/index.ts index b48cf58..02c1ee6 100644 --- a/packages/heuristic-table-plugin/src/index.ts +++ b/packages/heuristic-table-plugin/src/index.ts @@ -1,5 +1,5 @@ import { CustomBlockRenderer } from '@native-html/render'; -import { HeuristicTablePluginConfig, Settings } from './shared-types'; +import { HeuristicTablePluginConfig } from './shared-types'; import TableRenderer from './TableRenderer'; import TdRenderer from './TdRenderer'; import ThRenderer from './ThRenderer'; @@ -11,6 +11,7 @@ export { DisplayCell, HeuristicTablePluginConfig, HTMLTableProps, + Settings, TableCell, TableFlexColumnContainer, TableFlexRowContainer, @@ -45,7 +46,7 @@ declare module '@native-html/render' { /** * Configuration for `@native-html/heuristic-table-plugin` table renderer. */ - table?: Settings & HeuristicTablePluginConfig; + table?: HeuristicTablePluginConfig; } } diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index d81c155..c7c24f1 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -158,6 +158,19 @@ export type TableRenderNode = | TableFlexRowContainer | TableRoot; +/** + * Everything the table layout engine needs to lay a table out: the author + * configuration, plus the width the document offers it. + * + * @remarks + * This is resolved by {@link useHtmlTableProps} and handed to + * {@link HTMLTable}; it is not the shape a consumer writes. Author + * configuration goes to `renderersProps.table` as a + * {@link HeuristicTablePluginConfig}, which carries no + * {@link Settings.contentWidth}. + * + * @public + */ export interface Settings { getStyleForCell?: HeuristicTablePluginConfig['getStyleForCell']; /** @@ -201,7 +214,7 @@ export interface Settings { * @remarks * This is the width offered to the document as a whole. The horizontal * spacing of the table's ancestors, and of the table itself, is subtracted - * from it by {@link TableLayout}. + * from it by the table layout engine. */ contentWidth: number; } From fb65684491fad4694df7a30876b03c2458c02f5d Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 20:45:31 +0200 Subject: [PATCH 16/21] refactor(heuristic-table-plugin): share layout helpers and drop the ramda dependency --- ...tic-table-plugin.settings.basefontcoeff.md | 18 -- ...ic-table-plugin.settings.bordercollapse.md | 13 -- ...-table-plugin.settings.fontweightcoeffs.md | 18 -- ...stic-table-plugin.settings.forcestretch.md | 13 -- ...c-table-plugin.settings.getstyleforcell.md | 11 -- .../docs/heuristic-table-plugin.settings.md | 98 +---------- .../etc/heuristic-table-plugin.api.md | 8 +- packages/heuristic-table-plugin/package.json | 4 +- .../heuristic-table-plugin/src/HTMLTable.tsx | 38 ++-- .../heuristic-table-plugin/src/TableLayout.ts | 21 ++- .../src/TableRenderContext.ts | 44 +++++ .../src/TreeRenderer.tsx | 164 +++++++++--------- .../src/helpers/TCellConstraintsComputer.ts | 98 +++++------ .../TCellConstraintsComputer.test.ts | 36 ++++ .../__tests__/indexCellNeighbours.test.ts | 51 ++++++ .../src/helpers/__tests__/makeRows.test.ts | 5 +- .../src/helpers/__tests__/tableStyles.test.ts | 21 ++- .../src/helpers/composeCellStyle.ts | 25 +++ .../src/helpers/computeColumnWidths.ts | 19 +- .../src/helpers/extractColumnWidths.ts | 11 +- .../src/helpers/fillTableDisplay.ts | 25 +-- .../src/helpers/indexCellNeighbours.ts | 83 +++++++++ .../src/helpers/makeRows.ts | 24 ++- .../src/helpers/parseSpan.ts | 29 ++++ .../src/helpers/reduceColumnConstraints.ts | 4 +- .../src/helpers/resolveBorderSpacing.ts | 48 +++-- .../src/helpers/resolveTableStyles.ts | 19 +- .../heuristic-table-plugin/src/helpers/sum.ts | 4 + .../src/helpers/tableStyles.ts | 143 ++++++++------- .../src/shared-types.ts | 58 +++---- .../src/useHtmlTableCellProps.ts | 40 ++--- .../src/useHtmlTableProps.ts | 14 +- yarn.lock | 2 - 33 files changed, 652 insertions(+), 557 deletions(-) delete mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.basefontcoeff.md delete mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.bordercollapse.md delete mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.fontweightcoeffs.md delete mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.forcestretch.md delete mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.getstyleforcell.md create mode 100644 packages/heuristic-table-plugin/src/TableRenderContext.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/indexCellNeighbours.test.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/composeCellStyle.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/indexCellNeighbours.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/parseSpan.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/sum.ts diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.basefontcoeff.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.basefontcoeff.md deleted file mode 100644 index 5047e57..0000000 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.basefontcoeff.md +++ /dev/null @@ -1,18 +0,0 @@ - - -[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [baseFontCoeff](./heuristic-table-plugin.settings.basefontcoeff.md) - -## Settings.baseFontCoeff property - -The average advance width of one character, as a fraction of the font size, used to estimate how wide a cell's text is. - -**Signature:** - -```typescript -baseFontCoeff?: number; -``` - -## Remarks - -Text is never measured, only estimated: a cell's bounds are its character count times this coefficient times the font size. Raise it when tables come out too narrow and their text wraps more than it should, lower it when cells claim more width than their content occupies. - diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.bordercollapse.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.bordercollapse.md deleted file mode 100644 index 3eae822..0000000 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.bordercollapse.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [borderCollapse](./heuristic-table-plugin.settings.bordercollapse.md) - -## Settings.borderCollapse property - -Override the table's `border-collapse` mode. - -**Signature:** - -```typescript -borderCollapse?: 'collapse' | 'separate'; -``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.fontweightcoeffs.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.fontweightcoeffs.md deleted file mode 100644 index 503fce9..0000000 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.fontweightcoeffs.md +++ /dev/null @@ -1,18 +0,0 @@ - - -[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [fontWeightCoeffs](./heuristic-table-plugin.settings.fontweightcoeffs.md) - -## Settings.fontWeightCoeffs property - -How much wider text renders at a given font weight than at a regular one, keyed by the stringified `fontWeight`. - -**Signature:** - -```typescript -fontWeightCoeffs?: FontWeightCoefficients; -``` - -## Remarks - -Merged over the defaults rather than replacing them, so `{ bold: 1.05 }` retunes bold text alone and leaves the numeric weights as they were. A weight with no entry, before or after merging, costs nothing. Pass a referentially stable object — a fresh literal on every render relays out every table using it. - diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.forcestretch.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.forcestretch.md deleted file mode 100644 index dfa3e58..0000000 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.forcestretch.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [forceStretch](./heuristic-table-plugin.settings.forcestretch.md) - -## Settings.forceStretch property - -When true, force the table to stretch to the available width. - -**Signature:** - -```typescript -forceStretch?: boolean; -``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.getstyleforcell.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.getstyleforcell.md deleted file mode 100644 index be34ac9..0000000 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.getstyleforcell.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [Settings](./heuristic-table-plugin.settings.md) > [getStyleForCell](./heuristic-table-plugin.settings.getstyleforcell.md) - -## Settings.getStyleForCell property - -**Signature:** - -```typescript -getStyleForCell?: HeuristicTablePluginConfig['getStyleForCell']; -``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md index a2434e7..7b1ca0e 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md @@ -9,8 +9,9 @@ Everything the table layout engine needs to lay a table out: the author configur **Signature:** ```typescript -export interface Settings +export interface Settings extends HeuristicTablePluginConfig ``` +**Extends:** [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md) ## Remarks @@ -41,44 +42,6 @@ Description -[baseFontCoeff?](./heuristic-table-plugin.settings.basefontcoeff.md) - - - - - - - -number - - - - -_(Optional)_ The average advance width of one character, as a fraction of the font size, used to estimate how wide a cell's text is. - - - - - -[borderCollapse?](./heuristic-table-plugin.settings.bordercollapse.md) - - - - - - - -'collapse' \| 'separate' - - - - -_(Optional)_ Override the table's `border-collapse` mode. - - - - - [contentWidth](./heuristic-table-plugin.settings.contentwidth.md) @@ -95,63 +58,6 @@ number Available width at the root of the render tree, prior to scrolling. - - - -[fontWeightCoeffs?](./heuristic-table-plugin.settings.fontweightcoeffs.md) - - - - - - - -[FontWeightCoefficients](./heuristic-table-plugin.fontweightcoefficients.md) - - - - -_(Optional)_ How much wider text renders at a given font weight than at a regular one, keyed by the stringified `fontWeight`. - - - - - -[forceStretch?](./heuristic-table-plugin.settings.forcestretch.md) - - - - - - - -boolean - - - - -_(Optional)_ When true, force the table to stretch to the available width. - - - - - -[getStyleForCell?](./heuristic-table-plugin.settings.getstyleforcell.md) - - - - - - - -[HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md)\['getStyleForCell'\] - - - - -_(Optional)_ - - diff --git a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md index a8dc6e4..b68cdea 100644 --- a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md +++ b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md @@ -79,14 +79,8 @@ const renderers: Record<'th' | 'td' | 'table', CustomBlockRenderer>; export default renderers; // @public -export interface Settings { - baseFontCoeff?: number; - borderCollapse?: 'collapse' | 'separate'; +export interface Settings extends HeuristicTablePluginConfig { contentWidth: number; - fontWeightCoeffs?: FontWeightCoefficients; - forceStretch?: boolean; - // (undocumented) - getStyleForCell?: HeuristicTablePluginConfig['getStyleForCell']; } // @public diff --git a/packages/heuristic-table-plugin/package.json b/packages/heuristic-table-plugin/package.json index 7a14892..7a57944 100644 --- a/packages/heuristic-table-plugin/package.json +++ b/packages/heuristic-table-plugin/package.json @@ -59,9 +59,7 @@ }, "dependencies": { "@types/prop-types": "^15.7.15", - "@types/ramda": "^0.31.1", - "prop-types": "^15.8.1", - "ramda": "^0.32.0" + "prop-types": "^15.8.1" }, "peerDependencies": { "@native-html/render": ">=1.0.0-alpha.0", diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index 2a002ba..4a3fd93 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -1,6 +1,9 @@ -import React, { memo, PropsWithChildren } from 'react'; +import React, { memo, PropsWithChildren, useMemo } from 'react'; import { ScrollView, View } from 'react-native'; import TreeRenderer from './TreeRenderer'; +import TableRenderContext, { + TableRenderContextValue +} from './TableRenderContext'; import { HTMLTableProps } from './shared-types'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; @@ -77,6 +80,18 @@ const HTMLTable = memo(function HTMLTable({ // would spill the table out of every padded ancestor it sits in. const insets = layout.horizontalInsets; const tableBorderStyle = layout.tableBorderStyle; + const renderContext = useMemo( + () => ({ + borderSpacing: layout.borderSpacing, + cellStyles: layout.cellStyles, + borderCollapse: layout.borderCollapse, + tableBorderStyle, + maxX: layout.display.maxX, + maxY: layout.display.maxY, + config + }), + [layout, tableBorderStyle, config] + ); return ( - {React.createElement(TreeRenderer, { - node: layout.renderTree, - borderSpacing: layout.borderSpacing, - config, - cellStyles: layout.cellStyles, - borderCollapse: layout.borderCollapse, - // Cells need the edge the wrapper resolved, not just their position - // in the matrix: an outer boundary it leaves bare is still theirs. - tableBorderStyle, - maxX: layout.display.maxX, - maxY: layout.display.maxY, - renderIndex: props.renderIndex, - renderLength: props.renderLength - })} + + +
); diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 8e7a4fe..8ac74dc 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -1,4 +1,4 @@ -import { sum } from 'ramda'; +import sum from './helpers/sum'; import resolveBorderSpacing, { BorderSpacing } from './helpers/resolveBorderSpacing'; @@ -11,6 +11,7 @@ import fillTableDisplay, { createEmptyDisplay } from './helpers/fillTableDisplay'; import TCellConstraintsComputer from './helpers/TCellConstraintsComputer'; +import indexCellNeighbours from './helpers/indexCellNeighbours'; import { Display, Settings, TableCell, TableRoot } from './shared-types'; import extractColumnWidths from './helpers/extractColumnWidths'; import { clampWidth, resolveWidthConstraints } from './helpers/resolveWidth'; @@ -112,17 +113,25 @@ export default class TableLayout { forceStretch }); fillTableDisplay(tnode, display); + const neighbours = this.borderCollapse + ? indexCellNeighbours(display.cells) + : undefined; const spacingWidth = display.cells.length ? (display.maxX + 2) * this.borderSpacing.horizontal : 0; const declaredColumnWidths = extractColumnWidths(tnode); const configStyles = new Map(); + const computer = new TCellConstraintsComputer({ + baseFontCoeff: config.baseFontCoeff, + fontWeightCoeffs: config.fontWeightCoeffs + }); const measure = () => { const resolved = resolveTableStyles( display, style, this.borderCollapse, - configStyles + configStyles, + neighbours ); const insets = getHorizontalInsets({ ...style, @@ -132,15 +141,11 @@ export default class TableLayout { 0, usedTableWidth - insets - spacingWidth ); - const computer = new TCellConstraintsComputer({ - contentWidth: display.contentWidth, - baseFontCoeff: config.baseFontCoeff, - fontWeightCoeffs: config.fontWeightCoeffs - }); for (const cell of display.cells) { const constraints = computer.computeCellConstraints( cell.tnode, - resolved.cellStyles.get(cell.tnode)!.style + resolved.cellStyles.get(cell.tnode)!.style, + display.contentWidth ); // A spanning cell also occupies the gaps between its columns. const internalSpacing = (cell.lenX - 1) * this.borderSpacing.horizontal; diff --git a/packages/heuristic-table-plugin/src/TableRenderContext.ts b/packages/heuristic-table-plugin/src/TableRenderContext.ts new file mode 100644 index 0000000..d4843b2 --- /dev/null +++ b/packages/heuristic-table-plugin/src/TableRenderContext.ts @@ -0,0 +1,44 @@ +import { createContext } from 'react'; +import { ViewStyle } from 'react-native'; +import { TNode } from '@native-html/render'; +import { BorderSpacing } from './helpers/resolveBorderSpacing'; +import { ResolvedCellStyle } from './helpers/resolveTableStyles'; +import { HeuristicTablePluginConfig } from './shared-types'; + +/** + * Everything the render tree needs which is the same for every node in one + * table. + * + * @remarks + * Carried in context rather than threaded through {@link TreeRenderer}: the + * tree recurses through row and column containers to reach a cell, and every + * level in between would otherwise have to accept and forward values it makes + * no use of. + */ +export interface TableRenderContextValue { + borderSpacing: BorderSpacing; + cellStyles: ReadonlyMap; + borderCollapse: boolean; + /** + * The wrapper edge the collapsing model resolved. + * + * @remarks + * Cells need this, not just their position in the matrix: an outer boundary + * the wrapper leaves bare is still theirs to paint. + */ + tableBorderStyle: ViewStyle | null; + maxX: number; + maxY: number; + config?: HeuristicTablePluginConfig; +} + +const DEFAULT_CONTEXT: TableRenderContextValue = { + borderSpacing: { horizontal: 0, vertical: 0 }, + cellStyles: new Map(), + borderCollapse: false, + tableBorderStyle: null, + maxX: -1, + maxY: -1 +}; + +export default createContext(DEFAULT_CONTEXT); diff --git a/packages/heuristic-table-plugin/src/TreeRenderer.tsx b/packages/heuristic-table-plugin/src/TreeRenderer.tsx index 33b4943..43e53a8 100644 --- a/packages/heuristic-table-plugin/src/TreeRenderer.tsx +++ b/packages/heuristic-table-plugin/src/TreeRenderer.tsx @@ -1,10 +1,12 @@ -import React, { useMemo } from 'react'; -import { BorderSpacing } from './helpers/resolveBorderSpacing'; -import { StyleSheet, View, ViewStyle } from 'react-native'; -import { TNode, TNodeRenderer } from '@native-html/render'; -import { ResolvedCellStyle } from './helpers/resolveTableStyles'; -import { HeuristicTablePluginConfig, TableRenderNode } from './shared-types'; +import React, { useContext, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { TNodeRenderer } from '@native-html/render'; +import { + InternalTableCellPropsFromParent, + TableRenderNode +} from './shared-types'; import CellContentWidthContext from './CellContentWidthContext'; +import TableRenderContext from './TableRenderContext'; import { getHorizontalInsets } from './helpers/measure'; const styles = StyleSheet.create({ @@ -12,29 +14,49 @@ const styles = StyleSheet.create({ rowContainer: { flexDirection: 'row', flexGrow: 1 } }); +export interface TreeRendererProps { + node: TableRenderNode; + renderIndex: number; + renderLength: number; +} + +/** + * The height a row container owes to the `height` its source `tr` declared. + * + * @remarks + * The render tree replaces source rows with flex containers, so their height + * floor has to be recovered here; a row must still grow when its content is + * taller. A spanning cell does not impose its starting row's height on its + * whole synthetic row group. + */ +function getRowMinHeight(children: readonly TableRenderNode[]): number { + return children.reduce((height, child) => { + if (child.type !== 'cell' || child.lenY !== 1) return height; + const row = child.tnode.parent; + if (row?.tagName !== 'tr') return height; + const style = row.styles.nativeBlockRet; + return Math.max( + height, + typeof style.height === 'number' ? style.height : 0, + typeof style.minHeight === 'number' ? style.minHeight : 0 + ); + }, 0); +} + export default function TreeRenderer({ node, - borderSpacing = { horizontal: 0, vertical: 0 }, - config, - cellStyles, - borderCollapse, - tableBorderStyle, - maxX, - maxY, renderIndex, renderLength -}: { - node: TableRenderNode; - borderSpacing?: BorderSpacing; - renderIndex: number; - renderLength: number; - config?: HeuristicTablePluginConfig; - cellStyles: ReadonlyMap; - borderCollapse: boolean; - tableBorderStyle: ViewStyle | null; - maxX: number; - maxY: number; -}) { +}: TreeRendererProps) { + const { + borderSpacing, + cellStyles, + borderCollapse, + tableBorderStyle, + maxX, + maxY, + config + } = useContext(TableRenderContext); const cellContentBox = useMemo( () => node.type === 'cell' @@ -50,6 +72,16 @@ export default function TreeRenderer({ [node, cellStyles] ); if (node.type === 'cell') { + const propsFromParent: InternalTableCellPropsFromParent = { + cell: node, + collapsedMarginTop: null, + config, + resolvedCellStyle: cellStyles.get(node.tnode), + borderCollapse, + tableBorderStyle, + maxX, + maxY + }; return ( @@ -81,21 +102,6 @@ export default function TreeRenderer({ ); } if (node.type === 'root' || node.type === 'col-container') { - const children = (node.children as TableRenderNode[]).map((v, i) => - React.createElement(TreeRenderer, { - node: v, - key: i, - config, - borderSpacing, - cellStyles, - borderCollapse, - tableBorderStyle, - maxX, - maxY, - renderIndex: i, - renderLength: node.children.length - }) - ); return ( - {children} + ); } if (node.type === 'row-container') { - // The render tree replaces source rows with flex containers. Preserve - // their height floor here; a row must still grow when its content is taller. - // A spanning cell does not impose its starting row's height on its whole - // synthetic row group. - const minHeight = node.children.reduce((height, child) => { - if (child.type !== 'cell' || child.lenY !== 1) return height; - const row = child.tnode.parent; - if (row?.tagName !== 'tr') return height; - const style = row.styles.nativeBlockRet; - return Math.max( - height, - typeof style.height === 'number' ? style.height : 0, - typeof style.minHeight === 'number' ? style.minHeight : 0 - ); - }, 0); + const minHeight = getRowMinHeight(node.children); return ( 0 && { minHeight }]}> - {node.children.map((v, i) => - React.createElement(TreeRenderer, { - node: v, - key: i, - config, - borderSpacing, - cellStyles, - borderCollapse, - tableBorderStyle, - maxX, - maxY, - renderIndex: i, - renderLength: node.children.length - }) - )} + ); } return null; } + +/** Render every child of a container, each told where it sits among them. */ +function TreeRendererChildren({ + children +}: { + children: readonly TableRenderNode[]; +}) { + return ( + <> + {children.map((child, index) => ( + + ))} + + ); +} diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index 80e0339..f9f9f59 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -17,36 +17,16 @@ interface TextChunkStats { } interface TCellStats { - /** - * The cell's own horizontal insets: its padding and border. Margins are - * excluded because the cell renderer zeroes them, so reserving column width - * for one would leave a gap nothing ever paints. - */ - horizontalSpace: number; - /** - * The maximum of explicit widths or min-widths of the block elements *inside* - * this cell, including margins. Content-box against the cell, so the cell's - * own horizontal spacing still has to be added on top. - */ + /** Absolute widths imposed by descendants, including their margins. */ blockWidth: number; - /** - * The border-box width the cell itself declares, or `null` when it declares - * none. Already holds the cell's padding and border. - */ - cellBoxWidth: number | null; - /** - * Text stats in this cell. - */ textStats: TextChunkStats[][]; } -function getInitCellStats(style: ViewStyle): TCellStats { - return { - blockWidth: 0, - cellBoxWidth: null, - horizontalSpace: getHorizontalInsets(style), - textStats: [[]] - }; +interface IntrinsicCellConstraints { + blockWidth: number; + minWidth: number; + maxWidth: number; + contentDensity: number; } /** @@ -108,6 +88,9 @@ export const DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients = { }; export default class TCellConstraintsComputer { + // A computer belongs to one layout. Cell styles and available width can + // change between its passes; descendant content and font coefficients cannot. + private intrinsicConstraints = new WeakMap(); private baseFontCoeff: number; private fallbackFontSize: number; private contentWidth: number; @@ -149,7 +132,7 @@ export default class TCellConstraintsComputer { private assembleCellStats( tnode: TNode, stats: TCellStats, - cellStyle?: ViewStyle + isCell = false ): TCellStats { if (tnode.tagName === 'br') { stats.textStats.push([]); @@ -171,22 +154,11 @@ export default class TCellConstraintsComputer { if (separatesText) { stats.textStats.push([]); } - if (tnode.type === 'block') { - const width = this.resolveBlockWidth(tnode, cellStyle); + if (tnode.type === 'block' && !isCell) { + const width = this.resolveBlockWidth(tnode); if (width !== null) { - if (cellStyle) { - // React Native lays out with `box-sizing: border-box`, and CSS - // gives a table cell that same box model, so the width a cell - // declares already holds its padding and border. It is kept apart - // from the descendant widths below, which are content-box against - // the cell and so do have to grow by its spacing. Margins play no - // part either: a table cell has none, and the cell renderer zeroes - // whatever a stylesheet asked for. - stats.cellBoxWidth = width; - } else { - const margins = getHorizontalMargins(tnode.styles.nativeBlockRet); - stats.blockWidth = Math.max(stats.blockWidth, width + margins); - } + const margins = getHorizontalMargins(tnode.styles.nativeBlockRet); + stats.blockWidth = Math.max(stats.blockWidth, width + margins); } } tnode.children.forEach((n) => this.assembleCellStats(n, stats)); @@ -259,29 +231,47 @@ export default class TCellConstraintsComputer { return { minWidth, maxWidth, contentDensity }; } + private measureIntrinsicConstraints(tnode: TNode): IntrinsicCellConstraints { + const cached = this.intrinsicConstraints.get(tnode); + if (cached) return cached; + const stats = this.assembleCellStats( + tnode, + { blockWidth: 0, textStats: [[]] }, + true + ); + const constraints = { + blockWidth: stats.blockWidth, + ...this.computeTextConstraints(stats.textStats) + }; + this.intrinsicConstraints.set(tnode, constraints); + return constraints; + } + computeCellConstraints( tnode: TNode, - style: ViewStyle = getPaintedBlockStyle(tnode) + style: ViewStyle = getPaintedBlockStyle(tnode), + contentWidth = this.contentWidth ): TCellConstraints { - const stats = this.assembleCellStats(tnode, getInitCellStats(style), style); - const blockWidth = stats.blockWidth; - const textConstrains = this.computeTextConstraints(stats.textStats); + const intrinsic = this.measureIntrinsicConstraints(tnode); + const { blockWidth } = intrinsic; + const horizontalSpace = getHorizontalInsets(style); // A `max-width` on the cell itself caps the whole cell box. A descendant's // `max-width` must not, since it only bounds that descendant. - const cellMaxWidth = resolveCssSize(style.maxWidth, this.contentWidth); + const cellMaxWidth = resolveCssSize(style.maxWidth, contentWidth); // Per CSS 2.1 §17.5.2.2, "if the specified 'width' (W) of the cell is // greater than MCW, W is the minimum cell width", and the maximum cell // width is likewise raised by the column 'width'. So an explicit width // lifts *both* bounds — never just one, or the cell would end up // narrower than the width it asked for. Being a border-box width, it // bounds the spaced total rather than joining the content it holds. - const cellBoxWidth = stats.cellBoxWidth ?? 0; + const cellBoxWidth = + tnode.type === 'block' ? (this.resolveBlockWidth(tnode, style) ?? 0) : 0; const minWidth = Math.max( - Math.max(blockWidth, textConstrains.minWidth) + stats.horizontalSpace, + Math.max(blockWidth, intrinsic.minWidth) + horizontalSpace, cellBoxWidth ); const maxWidth = Math.max( - Math.max(blockWidth, textConstrains.maxWidth) + stats.horizontalSpace, + Math.max(blockWidth, intrinsic.maxWidth) + horizontalSpace, cellBoxWidth ); const percentage = resolvePercentage(style.width ?? tnode.attributes.width); @@ -291,13 +281,13 @@ export default class TCellConstraintsComputer { : Math.min( percentage, resolvePercentage(style.maxWidth) ?? percentage, - cellMaxWidth === null || this.contentWidth === 0 + cellMaxWidth === null || contentWidth === 0 ? percentage - : cellMaxWidth / this.contentWidth + : cellMaxWidth / contentWidth ); return { ...(percentWidth === null ? {} : { percentWidth }), - horizontalSpace: stats.horizontalSpace, + horizontalSpace, minWidth, // `max-width` caps the width the cell would *like*, but never takes it // below the width it needs to hold its longest word: min-content is a @@ -306,7 +296,7 @@ export default class TCellConstraintsComputer { cellMaxWidth === null ? maxWidth : Math.max(minWidth, Math.min(maxWidth, cellMaxWidth)), - contentDensity: textConstrains.contentDensity + contentDensity: intrinsic.contentDensity }; } } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index 2be5220..426cb98 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -5,6 +5,7 @@ import TCellConstraintsComputer, { import { TCellConstraints } from '../../shared-types'; import { DEFAULT_CELL_PADDING } from '../tableStyles'; import { createCellTNode } from '../../__tests__/utils'; +import { ViewStyle } from 'react-native'; /** * Pinned here so that the break-opportunity assertions below test the segment @@ -35,6 +36,41 @@ function constraintsFor( } describe('TCellConstraintsComputer', () => { + it('reuses descendant measurements while recomputing style and width constraints', () => { + const cell = createCellTNode( + '
some bold text
' + ); + const children = cell.children; + const readChildren = jest.fn(() => children); + Object.defineProperty(cell, 'children', { get: readChildren }); + const computer = new TCellConstraintsComputer({ baseFontCoeff: 0.5 }); + const cases: [ViewStyle, number][] = [ + [{ padding: 2, width: '50%', maxWidth: 80 }, 200], + [{ padding: 10, borderWidth: 3, width: '50%', maxWidth: 80 }, 400], + [{ padding: 0, width: 100, minWidth: 120 }, 300] + ]; + const results = cases.map(([style, width]) => + computer.computeCellConstraints(cell, style, width) + ); + expect(readChildren).toHaveBeenCalledTimes(1); + cases.forEach(([style, width], i) => { + expect(results[i]).toEqual( + new TCellConstraintsComputer({ + baseFontCoeff: 0.5, + contentWidth: width + }).computeCellConstraints(cell, style) + ); + }); + expect(results[0]!.percentWidth).toBe(0.4); + expect(results[1]!.percentWidth).toBe(0.2); + expect(results[1]!.horizontalSpace).toBe(26); + expect(results[2]!.minWidth).toBeGreaterThanOrEqual(120); + const retuned = new TCellConstraintsComputer({ + baseFontCoeff: 1 + }).computeCellConstraints(cell, cases[0]![0], 200); + expect(retuned.contentDensity).toBe(results[0]!.contentDensity * 2); + }); + describe('font weight coefficients', () => { it('should widen bold text by the default coefficient', () => { const { minWidth } = constraintsFor( diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/indexCellNeighbours.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/indexCellNeighbours.test.ts new file mode 100644 index 0000000..b47c81e --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/indexCellNeighbours.test.ts @@ -0,0 +1,51 @@ +import { DisplayCell } from '../../shared-types'; +import indexCellNeighbours, { CellNeighbours } from '../indexCellNeighbours'; +import { createCellTNode } from '../../__tests__/utils'; + +/** + * The adjacency rule stated directly, as the oracle the index is checked + * against. + * + * @remarks + * This used to live in `getCollapsedCellBorderStyle` as the fallback taken + * when no index was supplied, and the test compared the two code paths. Every + * collapsing caller now supplies an index, so the rule survives here alone — + * still an independent implementation, just no longer one that also ships. + */ +function neighboursByScan( + cells: readonly DisplayCell[], + cell: DisplayCell +): CellNeighbours { + return { + Right: cells.filter( + (other) => + other.x === cell.x + cell.lenX && + other.y < cell.y + cell.lenY && + other.y + other.lenY > cell.y + ), + Bottom: cells.filter( + (other) => + other.y === cell.y + cell.lenY && + other.x < cell.x + cell.lenX && + other.x + other.lenX > cell.x + ) + }; +} + +it('matches shared-edge searches for spans, holes, overlaps and document-order ties', () => { + const tnode = createCellTNode('
A
'); + // Deliberately unsorted and overlapping geometry exercises the behaviour for + // malformed tables as well as ordinary one-slot cells. + const cells: DisplayCell[] = Array.from({ length: 200 }, (_, i) => ({ + x: (i * 7) % 19, + y: (i * 11) % 23, + lenX: 1 + (i % 4), + lenY: 1 + (i % 5), + tnode, + constraints: { minWidth: 0, maxWidth: 0, contentDensity: 0 } + })); + const index = indexCellNeighbours(cells); + for (const cell of cells) { + expect(index.get(cell)).toEqual(neighboursByScan(cells, cell)); + } +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts index 39b76c3..0213a55 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts @@ -1,5 +1,4 @@ import { TNode } from '@native-html/render'; -import R from 'ramda'; import { TableCell } from '../../shared-types'; import makeRows from '../makeRows'; @@ -22,7 +21,7 @@ function cell(y: number, x: number = 0): TableCell { describe('makeRows', () => { it('should preserve order of rows', () => { - const cells = R.map(cell, R.range(0, 100)); - expect(R.flatten(makeRows(cells))).toMatchObject(cells); + const cells = Array.from({ length: 100 }, (_, y) => cell(y)); + expect(makeRows(cells).flat()).toMatchObject(cells); }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts index b1c2ce9..4decf72 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts @@ -373,15 +373,18 @@ describe('table styles', () => { maxX: 3, maxY: 3, tableBorderStyle: FRAMED, - cells: [ - { - x: 2, - y: 1, - lenX: 1, - lenY: 1, - tnode: createCellTNode('
A
') - } - ], + neighbours: { + Right: [ + { + x: 2, + y: 1, + lenX: 1, + lenY: 1, + tnode: createCellTNode('
A
') + } + ], + Bottom: [] + }, getCellStyle: () => ({ borderLeftWidth: 4, borderLeftColor: 'red' }) } ) diff --git a/packages/heuristic-table-plugin/src/helpers/composeCellStyle.ts b/packages/heuristic-table-plugin/src/helpers/composeCellStyle.ts new file mode 100644 index 0000000..64b32f3 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/composeCellStyle.ts @@ -0,0 +1,25 @@ +import { ViewStyle } from 'react-native'; +import { getDefaultCellPaddingStyle } from './tableStyles'; + +/** Shared precedence for measurement and rendering; config is normalized first. */ +export default function composeCellStyle( + source: ViewStyle, + configured: ViewStyle | null, + { + border = null, + rendererDefaults = {}, + paddingSource = source + }: { + border?: ViewStyle | null; + rendererDefaults?: ViewStyle; + paddingSource?: ViewStyle; + } = {} +): ViewStyle { + return { + ...getDefaultCellPaddingStyle(paddingSource, configured), + ...source, + ...rendererDefaults, + ...configured, + ...border + }; +} diff --git a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts index a6756e4..6dc5dc1 100644 --- a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts @@ -2,6 +2,7 @@ import { Display, TColumnConstraints } from '../shared-types'; import reduceColumnConstraints from './reduceColumnConstraints'; import type { DeclaredColumnWidth } from './extractColumnWidths'; import { clampWidth, lesserBound } from './resolveWidth'; +import sum from './sum'; /** Below this many pixels a leftover is not worth another distribution pass. */ const EPSILON = 1e-6; @@ -14,10 +15,6 @@ function mapSpreads(constraints: TColumnConstraints[]): number[] { return constraints.map((c) => c.spread); } -function sumOf(values: number[]): number { - return values.reduce((acc, x) => acc + x, 0); -} - /** * Share `total` across `weights`, proportionally. Falls back to an even share * when every weight is zero, so that no space is ever silently dropped. @@ -26,7 +23,7 @@ function distribute(total: number, weights: number[]): number[] { if (weights.length === 0) { return []; } - const totalWeight = sumOf(weights); + const totalWeight = sum(weights); if (totalWeight === 0) { return weights.map(() => total / weights.length); } @@ -38,8 +35,8 @@ function interpolateWidths( upper: number[], targetWidth: number ): number[] { - const lowerTotal = sumOf(lower); - const upperTotal = sumOf(upper); + const lowerTotal = sum(lower); + const upperTotal = sum(upper); if (upperTotal <= lowerTotal) { return lower; } @@ -212,7 +209,7 @@ export default function computeColumnWidths( } const minWidths = mapMinWidths(columnConstraints); const spreads = mapSpreads(columnConstraints); - const sumOfMinWidths = sumOf(minWidths); + const sumOfMinWidths = sum(minWidths); if (contentWidth < sumOfMinWidths) { // The table cannot fit: no column may go below the width it needs to hold // its longest word, so the table overflows and `HTMLTable` scrolls it. @@ -242,7 +239,7 @@ export default function computeColumnWidths( cap == null ? preferred : Math.min(preferred, cap) ); }); - const percentageGuessTotal = sumOf(percentageGuess); + const percentageGuessTotal = sum(percentageGuess); if (contentWidth <= percentageGuessTotal) { return interpolateWidths(minWidths, percentageGuess, contentWidth); } @@ -252,7 +249,7 @@ export default function computeColumnWidths( const maxContentGuess = percentageGuess.map((width, i) => percentages[i] == null ? Math.max(width, spreads[i] ?? 0) : width ); - const maxContentGuessTotal = sumOf(maxContentGuess); + const maxContentGuessTotal = sum(maxContentGuess); if (contentWidth <= maxContentGuessTotal) { return interpolateWidths(percentageGuess, maxContentGuess, contentWidth); } @@ -294,7 +291,7 @@ export default function computeColumnWidths( // left does the table stay narrower than its assignable width. let widths = maxContentGuess; for (const group of [autoColumns, percentColumns, allColumns]) { - const leftover = contentWidth - sumOf(widths); + const leftover = contentWidth - sum(widths); if (leftover <= EPSILON) { break; } diff --git a/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts index 885458e..9018777 100644 --- a/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/extractColumnWidths.ts @@ -4,6 +4,7 @@ import { resolveAttributeLength, resolvePercentage } from './resolveWidth'; +import parseSpan from './parseSpan'; /** * The width declarations a `col` or its `colgroup` contributes to one column. @@ -39,16 +40,6 @@ export interface DeclaredColumnWidth { maxPercent: number | null; } -const MAX_SPAN = 1000; - -function parseSpan(value: unknown): number { - const parsed = typeof value === 'string' ? Number(value.trim()) : NaN; - if (!Number.isFinite(parsed)) { - return 1; - } - return Math.min(Math.max(Math.floor(parsed), 1), MAX_SPAN); -} - function appendWidth( widths: Array, width: DeclaredColumnWidth | null, diff --git a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts index d30c163..eb86971 100644 --- a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts +++ b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts @@ -6,6 +6,7 @@ import { TCellConstraints } from '../shared-types'; import TCellConstraintsComputer from './TCellConstraintsComputer'; +import parseSpan, { MAX_COLSPAN, MAX_ROWSPAN } from './parseSpan'; /** * The constraints of a cell no computer has measured yet. @@ -33,30 +34,6 @@ export function createEmptyDisplay(config: Settings): Display { }; } -const MAX_COLSPAN = 1000; -const MAX_ROWSPAN = 65534; - -/** - * Parse a `colspan` / `rowspan` attribute the way HTML requires. - * - * @remarks - * The attribute is a non-negative integer, clamped to a maximum; anything - * invalid — a missing value, a negative, a fraction, `0`, or plain nonsense — - * falls back to `1`. Letting a raw `Number()` through instead lets `0` and - * negatives corrupt the grid cursor. - * - * Note that `rowspan="0"` means "span to the end of the row group" in HTML. - * Row groups are not modelled here, so it degrades to `1` rather than - * silently spanning nothing. - */ -function parseSpan(value: unknown, max: number): number { - const parsed = typeof value === 'string' ? Number(value.trim()) : NaN; - if (!Number.isFinite(parsed)) { - return 1; - } - return Math.min(Math.max(Math.floor(parsed), 1), max); -} - function isOccupied(display: Display, x: number, y: number): boolean { return display.occupiedCoordinates.some( (coordinates) => coordinates.x === x && coordinates.y === y diff --git a/packages/heuristic-table-plugin/src/helpers/indexCellNeighbours.ts b/packages/heuristic-table-plugin/src/helpers/indexCellNeighbours.ts new file mode 100644 index 0000000..6447a0e --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/indexCellNeighbours.ts @@ -0,0 +1,83 @@ +import { DisplayCell } from '../shared-types'; + +type Cell = Pick; +export interface CellNeighbours { + Right: readonly Cell[]; + Bottom: readonly Cell[]; +} + +interface Interval { + cell: Cell; + order: number; + start: number; + end: number; + maxEnd: number; +} + +function indexEdges(cells: readonly Cell[], horizontal: boolean) { + const edges = new Map(); + cells.forEach((cell, order) => { + const edge = horizontal ? cell.y : cell.x; + const start = horizontal ? cell.x : cell.y; + const end = start + (horizontal ? cell.lenX : cell.lenY); + const bucket = edges.get(edge) ?? []; + bucket.push({ cell, order, start, end, maxEnd: end }); + edges.set(edge, bucket); + }); + for (const bucket of edges.values()) { + bucket.sort((a, b) => a.start - b.start); + let maxEnd = -Infinity; + for (const interval of bucket) { + maxEnd = Math.max(maxEnd, interval.end); + interval.maxEnd = maxEnd; + } + } + return edges; +} + +function overlapping( + bucket: Interval[] = [], + start: number, + end: number +): Cell[] { + // Skip intervals ending before this side. Prefix maxima also handle + // overlapping spans in malformed markup without missing a long interval. + let low = 0; + let high = bucket.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (bucket[middle]!.maxEnd <= start) low = middle + 1; + else high = middle; + } + const matches: Interval[] = []; + for (let i = low; i < bucket.length && bucket[i]!.start < end; i++) { + if (bucket[i]!.end > start) matches.push(bucket[i]!); + } + // Equal-strength border conflicts retain document order, not spatial order. + return matches.sort((a, b) => a.order - b.order).map(({ cell }) => cell); +} + +/** Index geometry once; border styles may change between measurement passes. */ +export default function indexCellNeighbours( + cells: readonly Cell[] +): ReadonlyMap { + const leftEdges = indexEdges(cells, false); + const topEdges = indexEdges(cells, true); + return new Map( + cells.map((cell) => [ + cell, + { + Right: overlapping( + leftEdges.get(cell.x + cell.lenX), + cell.y, + cell.y + cell.lenY + ), + Bottom: overlapping( + topEdges.get(cell.y + cell.lenY), + cell.x, + cell.x + cell.lenX + ) + } + ]) + ); +} diff --git a/packages/heuristic-table-plugin/src/helpers/makeRows.ts b/packages/heuristic-table-plugin/src/helpers/makeRows.ts index da3ed6e..4bd7e0a 100644 --- a/packages/heuristic-table-plugin/src/helpers/makeRows.ts +++ b/packages/heuristic-table-plugin/src/helpers/makeRows.ts @@ -1,11 +1,25 @@ import { TableCell } from '../shared-types'; +/** + * Group cells into rows, ordered top to bottom. + * + * @remarks + * The sort is explicit rather than left to key iteration order: grouping into + * a plain object happens to come back in ascending row order only because `y` + * stringifies to an array index, which is a property of the keys rather than + * anything this function states. + */ export default function makeRows(cells: readonly TableCell[]): TableCell[][] { - const grouped: Record = {}; + const grouped = new Map(); for (const cell of cells) { - const key = String(cell.y); - if (!grouped[key]) grouped[key] = []; - grouped[key].push(cell); + const row = grouped.get(cell.y); + if (row) { + row.push(cell); + } else { + grouped.set(cell.y, [cell]); + } } - return Object.values(grouped); + return [...grouped.entries()] + .sort(([a], [b]) => a - b) + .map(([, row]) => row); } diff --git a/packages/heuristic-table-plugin/src/helpers/parseSpan.ts b/packages/heuristic-table-plugin/src/helpers/parseSpan.ts new file mode 100644 index 0000000..b42794a --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/parseSpan.ts @@ -0,0 +1,29 @@ +/** + * The largest `colspan` HTML allows, and the value `extractColumnWidths` uses + * for the `span` attribute of `col` and `colgroup`, which shares the limit. + */ +export const MAX_COLSPAN = 1000; + +/** The largest `rowspan` HTML allows. */ +export const MAX_ROWSPAN = 65534; + +/** + * Parse a `colspan`, `rowspan` or `span` attribute the way HTML requires. + * + * @remarks + * The attribute is a non-negative integer, clamped to a maximum; anything + * invalid — a missing value, a negative, a fraction, `0`, or plain nonsense — + * falls back to `1`. Letting a raw `Number()` through instead lets `0` and + * negatives corrupt the grid cursor. + * + * Note that `rowspan="0"` means "span to the end of the row group" in HTML. + * Row groups are not modelled here, so it degrades to `1` rather than + * silently spanning nothing. + */ +export default function parseSpan(value: unknown, max = MAX_COLSPAN): number { + const parsed = typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(parsed)) { + return 1; + } + return Math.min(Math.max(Math.floor(parsed), 1), max); +} diff --git a/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts b/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts index cd8f6ba..38835c5 100644 --- a/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts +++ b/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts @@ -1,5 +1,3 @@ -import flatten from 'ramda/src/flatten'; - import { CellProperties, TCellConstraints, @@ -52,7 +50,7 @@ function splitColspanCells(cell: CellProperties): CellProperties | CellPropertie export default function reduceColumnConstraints( cells: CellProperties[] ): TColumnConstraints[] { - const flatCells = flatten(cells.map(splitColspanCells)) as CellProperties[]; + const flatCells = cells.flatMap(splitColspanCells); if (flatCells.length === 0) { return []; } diff --git a/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts b/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts index b2e17e1..a89aa07 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts @@ -9,31 +9,47 @@ export interface BorderSpacing { const ZERO: BorderSpacing = { horizontal: 0, vertical: 0 }; +const LENGTH_REGEX = /^(\d*\.?\d+)(px|em|rem|pt|pc|in|cm|mm)?$/; + +/** CSS absolute units, in px. */ +const ABSOLUTE_SCALES: Record = { + px: 1, + pt: 96 / 72, + pc: 16, + in: 96, + cm: 96 / 2.54, + mm: 96 / 25.4 +}; + +const DEFAULT_FONT_SIZE = 16; + +function rootOf(node: TNode): TNode { + let root = node; + while (root.parent) root = root.parent; + return root; +} + function parseSpacing(value: string, node: TNode): BorderSpacing | null { const parts = value.split(/\s+/); if (parts.length < 1 || parts.length > 2) return null; + // The font-relative units resolve against this node, so the scales are the + // same for both parts and are built once rather than per part. + const scales: Record = { + ...ABSOLUTE_SCALES, + em: node.styles.nativeTextFlow.fontSize ?? DEFAULT_FONT_SIZE, + rem: rootOf(node).styles.nativeTextFlow.fontSize ?? DEFAULT_FONT_SIZE + }; const lengths = parts.map((part) => { - const match = /^(\d*\.?\d+)(px|em|rem|pt|pc|in|cm|mm)?$/.exec(part); + const match = LENGTH_REGEX.exec(part); if (!match) return NaN; const number = Number(match[1]); const unit = match[2]; - if (!unit && number !== 0) return NaN; - let root = node; - while (root.parent) root = root.parent; - const scales: Record = { - px: 1, - em: node.styles.nativeTextFlow.fontSize ?? 16, - rem: root.styles.nativeTextFlow.fontSize ?? 16, - pt: 96 / 72, - pc: 16, - in: 96, - cm: 96 / 2.54, - mm: 96 / 25.4 - }; - return number * (unit ? scales[unit] : 1); + // Only zero may go unitless; every other bare number is invalid CSS. + if (!unit) return number === 0 ? 0 : NaN; + return number * scales[unit]!; }); if (lengths.some((length) => !Number.isFinite(length))) return null; - return { horizontal: lengths[0], vertical: lengths[1] ?? lengths[0] }; + return { horizontal: lengths[0]!, vertical: lengths[1] ?? lengths[0]! }; } /** Unsupported web-only CSS survives on the source attributes, not native styles. */ diff --git a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts index b061ec4..69c54f9 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts @@ -4,9 +4,10 @@ import { Display } from '../shared-types'; import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle, - getDefaultCellPaddingStyle, resolveConfiguredCellStyle } from './tableStyles'; +import composeCellStyle from './composeCellStyle'; +import indexCellNeighbours from './indexCellNeighbours'; /** One saved style resolution shared by measurement and rendering. */ export interface ResolvedCellStyle { @@ -19,17 +20,16 @@ export default function resolveTableStyles( display: Display, tableStyle: ViewStyle, collapse: boolean, - configStyles: ReadonlyMap + configStyles: ReadonlyMap, + neighbours = collapse ? indexCellNeighbours(display.cells) : undefined ) { const styles = new Map(); + const configuredStyles = new Map(); for (const { tnode } of display.cells) { const source = tnode.styles.nativeBlockRet; const configured = resolveConfiguredCellStyle(configStyles.get(tnode)); - styles.set(tnode, { - ...getDefaultCellPaddingStyle(source, configured), - ...source, - ...configured - }); + configuredStyles.set(tnode, configured); + styles.set(tnode, composeCellStyle(source, configured)); } const getCellStyle = ({ tnode }: { tnode: TNode }) => styles.get(tnode)!; const tableBorderStyle = collapse @@ -41,11 +41,12 @@ export default function resolveTableStyles( ? getCollapsedCellBorderStyle(cell, getCellStyle(cell), { ...display, tableBorderStyle, - getCellStyle + getCellStyle, + neighbours: neighbours?.get(cell) }) : null; cellStyles.set(cell.tnode, { - configStyle: resolveConfiguredCellStyle(configStyles.get(cell.tnode)), + configStyle: configuredStyles.get(cell.tnode)!, borderStyle, style: { ...getCellStyle(cell), ...borderStyle } }); diff --git a/packages/heuristic-table-plugin/src/helpers/sum.ts b/packages/heuristic-table-plugin/src/helpers/sum.ts new file mode 100644 index 0000000..5c23231 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/sum.ts @@ -0,0 +1,4 @@ +/** Total of `values`, or `0` when there are none. */ +export default function sum(values: readonly number[]): number { + return values.reduce((total, value) => total + value, 0); +} diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index 19e03f4..9565636 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -1,6 +1,7 @@ import { I18nManager, ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; import { Display, DisplayCell, TableCell } from '../shared-types'; +import type { CellNeighbours } from './indexCellNeighbours'; export type BorderCollapse = 'collapse' | 'separate'; @@ -79,7 +80,33 @@ export const DEFAULT_CELL_VERTICAL_ALIGN: CellVerticalAlign = 'middle'; */ export const DEFAULT_CELL_PADDING = 1; -type PaddingSide = 'Bottom' | 'Left' | 'Right' | 'Top'; +/** The four physical edges of a box, spelled as React Native style suffixes. */ +export type BoxSide = 'Bottom' | 'Left' | 'Right' | 'Top'; + +export const BOX_SIDES: readonly BoxSide[] = ['Top', 'Right', 'Bottom', 'Left']; + +/** + * Whether a style resolves its logical edges right-to-left. + * + * @remarks + * An explicit `direction` wins; otherwise the app-wide setting decides, which + * is what Yoga itself does with an unset direction. + */ +export function isRTL(style: ViewStyle): boolean { + return ( + style.direction === 'rtl' || + (style.direction !== 'ltr' && I18nManager.isRTL) + ); +} + +/** The logical edge a physical horizontal side maps to, or `null` vertically. */ +function logicalSideOf(side: BoxSide, rtl: boolean): 'End' | 'Start' | null { + if (side === 'Left') return rtl ? 'End' : 'Start'; + if (side === 'Right') return rtl ? 'Start' : 'End'; + return null; +} + +type PaddingSide = BoxSide; /** * Every style property which declares padding on a given side. @@ -294,7 +321,7 @@ export function resolveBorderCollapse( return false; } -type BorderSide = 'Bottom' | 'Left' | 'Right' | 'Top'; +type BorderSide = BoxSide; interface BorderCandidate { color: ViewStyle['borderColor']; @@ -314,19 +341,7 @@ function borderCandidate( side: BorderSide, fromCell: boolean ): BorderCandidate { - const rtl = - style.direction === 'rtl' || - (style.direction !== 'ltr' && I18nManager.isRTL); - const logicalSide = - side === 'Left' - ? rtl - ? 'End' - : 'Start' - : side === 'Right' - ? rtl - ? 'Start' - : 'End' - : null; + const logicalSide = logicalSideOf(side, isRTL(style)); // The CSS processor always expands `border` per side, but // `getStyleForCell` is hand-written and the shorthand is the natural way to // reach for a border there, so fall back to it. An explicit per-side `0` @@ -399,22 +414,40 @@ type CollapsibleMatrix = { cells: readonly C[]; } & Pick; +/** + * Whether a cell sits against one of the table's own edges. + * + * @remarks + * Shared by both collapsing passes on purpose. The wrapper resolves an outer + * border from the cells at an edge, and each cell then decides whether that + * same edge is its own; the two must agree, or a boundary is painted twice or + * not at all. + * + * A span that overruns the matrix is clipped to it rather than growing the + * table, so it sits at the edge it overran — hence `>=` rather than `===`. + */ +function isAtOuterEdge( + cell: Pick, + side: BorderSide, + { maxX, maxY }: Pick +): boolean { + switch (side) { + case 'Top': + return cell.y === 0; + case 'Right': + return cell.x + cell.lenX - 1 >= maxX; + case 'Bottom': + return cell.y + cell.lenY - 1 >= maxY; + case 'Left': + return cell.x === 0; + } +} + function cellsAtOuterEdge( { cells, maxX, maxY }: CollapsibleMatrix, side: BorderSide ): readonly C[] { - return cells.filter((cell) => { - switch (side) { - case 'Top': - return cell.y === 0; - case 'Right': - return cell.x + cell.lenX - 1 >= maxX; - case 'Bottom': - return cell.y + cell.lenY - 1 >= maxY; - case 'Left': - return cell.x === 0; - } - }); + return cells.filter((cell) => isAtOuterEdge(cell, side, { maxX, maxY })); } function sourceCellStyle(cell: CollapsibleCell): ViewStyle { @@ -480,8 +513,16 @@ export interface CollapsedCellEdges { maxX: number; maxY: number; tableBorderStyle: ViewStyle | null; - /** All cells and their uncollapsed styles, for shared-edge conflicts. */ - cells?: readonly CollapsibleCell[]; + /** + * The cells sharing this cell's trailing and bottom boundary, from + * {@link indexCellNeighbours}. + * + * @remarks + * Absent when the caller has no matrix to index — a `td` renderer reached + * outside this plugin's table — in which case the cell keeps its own + * borders rather than resolving them against neighbours it cannot see. + */ + neighbours?: CellNeighbours; getCellStyle?: (cell: CollapsibleCell) => ViewStyle; } @@ -510,19 +551,13 @@ export function getCollapsedCellBorderStyle( maxX, maxY, tableBorderStyle, - cells = [], + neighbours, getCellStyle = sourceCellStyle }: CollapsedCellEdges ): ViewStyle { const resolvedStyle: ViewStyle = clearLogicalBorders(cellStyle); - // A span that overruns the matrix is clipped to it rather than growing the - // table, so it sits at the edge it overran. - const isOuterEdge: Record = { - Top: cell.y === 0, - Right: cell.x + cell.lenX - 1 >= maxX, - Bottom: cell.y + cell.lenY - 1 >= maxY, - Left: cell.x === 0 - }; + const isOuterEdge = (side: BorderSide) => + isAtOuterEdge(cell, side, { maxX, maxY }); const isPaintedByTable = (side: BorderSide) => { const width = tableBorderStyle?.[`border${side}Width`]; return typeof width === 'number' && width > 0; @@ -550,34 +585,24 @@ export function getCollapsedCellBorderStyle( isPaintedByTable(side) ? null : ownBorder(side); // A leading boundary is always drawn by the neighbour that precedes it, // except on the outside where there is no neighbour to draw it. - paint('Top', isOuterEdge.Top ? keepOuterBorder('Top') : null); - paint('Left', isOuterEdge.Left ? keepOuterBorder('Left') : null); + paint('Top', isOuterEdge('Top') ? keepOuterBorder('Top') : null); + paint('Left', isOuterEdge('Left') ? keepOuterBorder('Left') : null); for (const [side, opposite] of [ ['Right', 'Left'], ['Bottom', 'Top'] ] as const) { paint( side, - isOuterEdge[side] + isOuterEdge(side) ? keepOuterBorder(side) - : cells - .filter((neighbour) => - side === 'Right' - ? neighbour.x === cell.x + cell.lenX && - neighbour.y < cell.y + cell.lenY && - neighbour.y + neighbour.lenY > cell.y - : neighbour.y === cell.y + cell.lenY && - neighbour.x < cell.x + cell.lenX && - neighbour.x + neighbour.lenX > cell.x - ) - .reduce( - (winner, neighbour) => - resolveBorderConflict( - winner, - borderCandidate(getCellStyle(neighbour), opposite, true) - ), - ownBorder(side) - ) + : (neighbours?.[side] ?? []).reduce( + (winner, neighbour) => + resolveBorderConflict( + winner, + borderCandidate(getCellStyle(neighbour), opposite, true) + ), + ownBorder(side) + ) ); } if (strongestStyle !== null) resolvedStyle.borderStyle = strongestStyle; diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index c7c24f1..31d0f62 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -6,6 +6,7 @@ import { TNode } from '@native-html/render'; import TableLayout from './TableLayout'; +import type { ResolvedCellStyle } from './helpers/resolveTableStyles'; import type { FontWeightCoefficients } from './helpers/TCellConstraintsComputer'; /** @@ -171,43 +172,7 @@ export type TableRenderNode = * * @public */ -export interface Settings { - getStyleForCell?: HeuristicTablePluginConfig['getStyleForCell']; - /** - * When true, force the table to stretch to the available width. - */ - forceStretch?: boolean; - /** - * The average advance width of one character, as a fraction of the font - * size, used to estimate how wide a cell's text is. - * - * @remarks - * Text is never measured, only estimated: a cell's bounds are its character - * count times this coefficient times the font size. Raise it when tables - * come out too narrow and their text wraps more than it should, lower it - * when cells claim more width than their content occupies. - * - * @defaultValue 0.65 - */ - baseFontCoeff?: number; - /** - * How much wider text renders at a given font weight than at a regular one, - * keyed by the stringified `fontWeight`. - * - * @remarks - * Merged over the defaults rather than replacing them, so `{ bold: 1.05 }` - * retunes bold text alone and leaves the numeric weights as they were. A - * weight with no entry, before or after merging, costs nothing. Pass a - * referentially stable object — a fresh literal on every render relays out - * every table using it. - * - * @defaultValue \{ normal: 1, bold: 1.3, '100': 0.8 … '900': 1.5 \} - */ - fontWeightCoeffs?: FontWeightCoefficients; - /** - * Override the table's `border-collapse` mode. - */ - borderCollapse?: 'collapse' | 'separate'; +export interface Settings extends HeuristicTablePluginConfig { /** * Available width at the root of the render tree, prior to scrolling. * @@ -325,3 +290,22 @@ export interface TableCellPropsFromParent extends PropsFromParent { config?: HeuristicTablePluginConfig; cell: TableCell; } + +/** + * What {@link TreeRenderer} hands a cell renderer on top of + * {@link TableCellPropsFromParent}. + * + * @remarks + * Internal: these are the values the table already resolved, passed down so a + * cell need not recompute them. A custom `td`/`th` renderer reached outside + * this plugin's table sees only the public fields, which is why every addition + * here is optional or has a defined absent state. + */ +export interface InternalTableCellPropsFromParent + extends TableCellPropsFromParent { + resolvedCellStyle?: ResolvedCellStyle; + borderCollapse: boolean; + maxX: number; + maxY: number; + tableBorderStyle: ViewStyle | null; +} diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index 3796077..57465c5 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -1,12 +1,11 @@ import { ViewStyle } from 'react-native'; import { TBlock, CustomRendererProps } from '@native-html/render'; -import { TableCellPropsFromParent } from './shared-types'; -import { ResolvedCellStyle } from './helpers/resolveTableStyles'; +import { InternalTableCellPropsFromParent } from './shared-types'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; +import composeCellStyle from './helpers/composeCellStyle'; import { CellVerticalAlign, getCollapsedCellBorderStyle, - getDefaultCellPaddingStyle, resolveConfiguredCellStyle, resolveCellVerticalAlign } from './helpers/tableStyles'; @@ -28,14 +27,6 @@ const justifyContentForVerticalAlign: Record< top: 'flex-start' }; -interface InternalTableCellPropsFromParent extends TableCellPropsFromParent { - resolvedCellStyle?: ResolvedCellStyle; - borderCollapse: boolean; - maxX: number; - maxY: number; - tableBorderStyle: ViewStyle | null; -} - /** * Customize `td` and `th` renderers while reusing default cell renderer logic. * @@ -81,28 +72,21 @@ export default function useHtmlTableCellProps({ : borderCollapse ? getCollapsedCellBorderStyle( cell, - { ...props.tnode.styles.nativeBlockRet, ...styleFromConfig }, + composeCellStyle(props.tnode.styles.nativeBlockRet, styleFromConfig), { maxX, maxY, tableBorderStyle } ) : null; - // The user-agent padding is resolved against the config styles too, since a - // shorthand `padding` there cannot outrank a longhand default whatever the - // merge order: Yoga resolves each side against its own edge first. - const defaultPaddingStyle = getDefaultCellPaddingStyle( - props.tnode.styles.nativeBlockRet, - styleFromConfig - ); const style = { - // The user-agent stylesheet is the weakest declaration of the three, and - // only covers the sides no author declaration reached. - ...defaultPaddingStyle, // Cells must fit their content even inside a fixed-height table viewport. - ...relaxHeightConstraint(props.style), - flexGrow: 1, - flexShrink: 0, - ...alignmentStyles, - ...relaxHeightConstraint(styleFromConfig ?? {}), - ...collapsedBorderStyle, + ...composeCellStyle( + relaxHeightConstraint(props.style), + styleFromConfig ? relaxHeightConstraint(styleFromConfig) : null, + { + border: collapsedBorderStyle, + rendererDefaults: { flexGrow: 1, flexShrink: 0, ...alignmentStyles }, + paddingSource: props.tnode.styles.nativeBlockRet + } + ), width: cell.width, marginLeft: 0, marginRight: 0, diff --git a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts index 8633eb9..d4f705d 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts @@ -9,9 +9,19 @@ import { useContentWidth, useRendererProps } from '@native-html/render'; -import { Settings, HTMLTableProps } from './shared-types'; +import { HeuristicTablePluginConfig, Settings, HTMLTableProps } from './shared-types'; import TableLayout from './TableLayout'; +/** + * Stands in for an absent `renderersProps.table`. + * + * @remarks + * Shared rather than built per render, so that a document configuring no + * table options still hands `HTMLTable` a stable `config` and lets its + * `memo` hold. + */ +const EMPTY_CONFIG: HeuristicTablePluginConfig = {}; + function useTableLayout({ tnode, settings, @@ -86,7 +96,7 @@ export default function useHtmlTableProps( return { layout, settings, - config: table || {}, + config: table ?? EMPTY_CONFIG, sharedProps, tnode, ...props diff --git a/yarn.lock b/yarn.lock index 868d2e9..019c78d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3541,7 +3541,6 @@ __metadata: "@types/html-validator": "npm:^5.0.6" "@types/jest": "npm:^30.0.0" "@types/prop-types": "npm:^15.7.15" - "@types/ramda": "npm:^0.31.1" "@types/react": "npm:^19.2.14" "@types/react-native": "npm:^0.73.0" babel-jest: "npm:^30.2.0" @@ -3551,7 +3550,6 @@ __metadata: metro-react-native-babel-preset: "npm:^0.77.0" metro-react-native-babel-transformer: "npm:^0.77.0" prop-types: "npm:^15.8.1" - ramda: "npm:^0.32.0" react: "npm:19.2.0" react-native: "npm:0.83.2" react-native-builder-bob: "npm:^0.40.18" From c048d04bd5edbca9c275c9ccc4ce2d771b7e50f1 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 21:15:35 +0200 Subject: [PATCH 17/21] fix(heuristic-table-plugin): render stray cells without crashing and honour authored direction --- .../docs/heuristic-table-plugin.settings.md | 6 ++- .../etc/heuristic-table-plugin.api.md | 2 +- .../heuristic-table-plugin/src/TableLayout.ts | 7 ++- .../src/__tests__/strayCell.test.tsx | 28 +++++++++++ .../src/__tests__/writingDirection.test.ts | 50 +++++++++++++++++++ .../src/helpers/resolveTableStyles.ts | 5 +- .../src/helpers/tableStyles.ts | 25 +++++++++- .../src/shared-types.ts | 10 +++- .../src/useHtmlTableCellProps.ts | 38 ++++++++++++-- 9 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 packages/heuristic-table-plugin/src/__tests__/strayCell.test.tsx create mode 100644 packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md index 7b1ca0e..3f1e785 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md @@ -9,14 +9,16 @@ Everything the table layout engine needs to lay a table out: the author configur **Signature:** ```typescript -export interface Settings extends HeuristicTablePluginConfig +export interface Settings extends Omit ``` -**Extends:** [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md) +**Extends:** Omit<[HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md), 'growBeyondHeight'> ## Remarks This is resolved by [useHtmlTableProps()](./heuristic-table-plugin.usehtmltableprops.md) and handed to [HTMLTable](./heuristic-table-plugin.htmltable.md); it is not the shape a consumer writes. Author configuration goes to `renderersProps.table` as a [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md), which carries no [Settings.contentWidth](./heuristic-table-plugin.settings.contentwidth.md). +[HeuristicTablePluginConfig.growBeyondHeight](./heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md) is deliberately absent: it decides whether a declared table `height` becomes a viewport or a minimum, which is a rendering choice [HTMLTable](./heuristic-table-plugin.htmltable.md) reads from the config directly. Excluding it here keeps `useHtmlTableProps` from having to copy a field no layout pass reads — and makes that a compile error rather than a silent omission if it ever does. + ## Properties
diff --git a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md index b68cdea..46f8a50 100644 --- a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md +++ b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md @@ -79,7 +79,7 @@ const renderers: Record<'th' | 'td' | 'table', CustomBlockRenderer>; export default renderers; // @public -export interface Settings extends HeuristicTablePluginConfig { +export interface Settings extends Omit { contentWidth: number; } diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 8ac74dc..0810baa 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -17,7 +17,10 @@ import extractColumnWidths from './helpers/extractColumnWidths'; import { clampWidth, resolveWidthConstraints } from './helpers/resolveWidth'; import resolveAvailableWidth from './helpers/resolveAvailableWidth'; import { getHorizontalInsets, getHorizontalMargins } from './helpers/measure'; -import { resolveBorderCollapse } from './helpers/tableStyles'; +import { + getSourceBlockStyle, + resolveBorderCollapse +} from './helpers/tableStyles'; import resolveTableStyles, { ResolvedCellStyle } from './helpers/resolveTableStyles'; @@ -66,7 +69,7 @@ export default class TableLayout { public readonly cells: TableCell[]; public readonly renderTree: TableRoot; constructor(tnode: TNode, config: Settings, cellContentBox?: CellContentBox) { - const style = tnode.styles.nativeBlockRet; + const style = getSourceBlockStyle(tnode); this.borderCollapse = resolveBorderCollapse(tnode, config.borderCollapse); this.borderSpacing = resolveBorderSpacing(tnode, this.borderCollapse); const containingWidth = resolveAvailableWidth( diff --git a/packages/heuristic-table-plugin/src/__tests__/strayCell.test.tsx b/packages/heuristic-table-plugin/src/__tests__/strayCell.test.tsx new file mode 100644 index 0000000..42b7a8c --- /dev/null +++ b/packages/heuristic-table-plugin/src/__tests__/strayCell.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { RenderHTML } from '@native-html/render'; +import renderers from '../index'; + +describe('cell renderers reached outside a plugin table', () => { + it('renders a stray td rather than throwing', () => { + const { getByText } = render( + Hello' }} + renderers={renderers as any} + /> + ); + expect(getByText('Hello')).toBeTruthy(); + }); + + it('renders when a document registers td without the table renderer', () => { + const { getByText } = render( +
A
' }} + renderers={{ td: renderers.td } as any} + /> + ); + expect(getByText('A')).toBeTruthy(); + }); +}); diff --git a/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts b/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts new file mode 100644 index 0000000..5e6383a --- /dev/null +++ b/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts @@ -0,0 +1,50 @@ +import { createTableTNode } from './utils'; +import TableLayout from '../TableLayout'; +import { getSourceBlockStyle } from '../helpers/tableStyles'; + +describe('authored writing direction', () => { + it('is recovered from the flow styles, where the processor files it', () => { + const table = createTableTNode( + '
A
' + ); + // `direction` is the sole block-flow property, so it never appears in the + // retained box styles every other pass reads. + expect(table.styles.nativeBlockRet).not.toHaveProperty('direction'); + expect(getSourceBlockStyle(table)).toMatchObject({ direction: 'rtl' }); + }); + + it('inherits to a cell that declares none of its own', () => { + const table = createTableTNode( + '
A
' + ); + const layout = new TableLayout(table, { contentWidth: 400 }); + expect(getSourceBlockStyle(layout.cells[0]!.tnode)).toMatchObject({ + direction: 'rtl' + }); + }); + + it.each([ + ['ltr', { borderLeftWidth: 7, borderRightWidth: 0 }], + ['rtl', { borderLeftWidth: 0, borderRightWidth: 7 }] + ] as const)( + 'resolves a configured logical border onto the physical side %s implies', + (direction, expected) => { + const layout = new TableLayout( + createTableTNode( + `` + + '
A
' + ), + { + contentWidth: 400, + // Logical edges only reach a cell through the config callback: the + // CSS processor drops `border-inline-start` and friends entirely. + getStyleForCell: () => ({ + borderStartWidth: 7, + borderStartColor: 'red' + }) + } + ); + expect(layout.tableBorderStyle).toMatchObject(expected); + } + ); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts index 69c54f9..be00d40 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts @@ -4,7 +4,8 @@ import { Display } from '../shared-types'; import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle, - resolveConfiguredCellStyle + resolveConfiguredCellStyle, + getSourceBlockStyle } from './tableStyles'; import composeCellStyle from './composeCellStyle'; import indexCellNeighbours from './indexCellNeighbours'; @@ -26,7 +27,7 @@ export default function resolveTableStyles( const styles = new Map(); const configuredStyles = new Map(); for (const { tnode } of display.cells) { - const source = tnode.styles.nativeBlockRet; + const source = getSourceBlockStyle(tnode); const configured = resolveConfiguredCellStyle(configStyles.get(tnode)); configuredStyles.set(tnode, configured); styles.set(tnode, composeCellStyle(source, configured)); diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index 9565636..0ddbb29 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -161,6 +161,27 @@ const paddingSideKeys: Record = { ] }; +/** + * The source block style of a node, with its writing direction folded in. + * + * @remarks + * `direction` is a flow property, not a retained box one: the CSS processor + * files it under `nativeBlockFlow` (`makePropertiesValidators`, the sole + * member of the block-flow model), and unlike `nativeBlockRet` that bag is + * inherited — a cell of a `` carries `rtl` + * without declaring it. + * + * Every pass which resolves a *logical* edge has to see it: {@link isRTL} + * here, and `getHorizontalInsets` in `measure`. Reading `nativeBlockRet` alone + * makes an authored `direction` invisible, so an RTL table resolves its + * logical borders and padding onto the wrong physical side. + */ +export function getSourceBlockStyle(tnode: TNode): ViewStyle { + const style = tnode.styles.nativeBlockRet; + const direction = tnode.styles.nativeBlockFlow?.direction; + return direction == null ? style : { ...style, direction }; +} + /** * Whether a node is a table cell, and so subject to the cell rules of the * user-agent stylesheet. @@ -181,7 +202,7 @@ export function isTableCell(tnode: TNode): boolean { export function getPaintedBlockStyle( tnode: TNode ): TNode['styles']['nativeBlockRet'] { - const style = tnode.styles.nativeBlockRet; + const style = getSourceBlockStyle(tnode); if (!isTableCell(tnode)) { return style; } @@ -451,7 +472,7 @@ function cellsAtOuterEdge( } function sourceCellStyle(cell: CollapsibleCell): ViewStyle { - return cell.tnode.styles.nativeBlockRet; + return getSourceBlockStyle(cell.tnode); } /** diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 31d0f62..50bb28e 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -170,9 +170,17 @@ export type TableRenderNode = * {@link HeuristicTablePluginConfig}, which carries no * {@link Settings.contentWidth}. * + * {@link HeuristicTablePluginConfig.growBeyondHeight} is deliberately absent: + * it decides whether a declared table `height` becomes a viewport or a + * minimum, which is a rendering choice {@link HTMLTable} reads from the config + * directly. Excluding it here keeps `useHtmlTableProps` from having to copy a + * field no layout pass reads — and makes that a compile error rather than a + * silent omission if it ever does. + * * @public */ -export interface Settings extends HeuristicTablePluginConfig { +export interface Settings + extends Omit { /** * Available width at the root of the render tree, prior to scrolling. * diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index 57465c5..a81984f 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -1,5 +1,9 @@ import { ViewStyle } from 'react-native'; -import { TBlock, CustomRendererProps } from '@native-html/render'; +import { + TBlock, + CustomRendererProps, + PropsFromParent +} from '@native-html/render'; import { InternalTableCellPropsFromParent } from './shared-types'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; import composeCellStyle from './helpers/composeCellStyle'; @@ -7,7 +11,8 @@ import { CellVerticalAlign, getCollapsedCellBorderStyle, resolveConfiguredCellStyle, - resolveCellVerticalAlign + resolveCellVerticalAlign, + getSourceBlockStyle } from './helpers/tableStyles'; /** @@ -27,6 +32,23 @@ const justifyContentForVerticalAlign: Record< top: 'flex-start' }; +/** + * Whether a cell renderer was reached through this plugin's own table. + * + * @remarks + * `PropsFromParent` extends `Record`, so the props a `td` or `th` + * renderer receives type-check whatever produced them. A cell rendered outside + * a {@link TableRenderer} — a stray `td` in a fragment, or a document that + * registered this plugin's `td` renderer without its `table` renderer — gets + * none of the layout below, and every field this hook reads is absent. + */ +function isTableCellPropsFromParent( + propsFromParent: PropsFromParent | undefined +): propsFromParent is InternalTableCellPropsFromParent { + return typeof (propsFromParent as Partial) + ?.cell?.lenX === 'number'; +} + /** * Customize `td` and `th` renderers while reusing default cell renderer logic. * @@ -38,6 +60,12 @@ export default function useHtmlTableCellProps({ propsFromParent, ...props }: CustomRendererProps): CustomRendererProps { + if (!isTableCellPropsFromParent(propsFromParent)) { + // Nothing laid this cell out, so there is no width, no matrix position and + // no resolved style to apply. Render it as the plain block it is rather + // than reaching into a layout that was never built. + return { ...props, propsFromParent }; + } const { borderCollapse, config, @@ -46,7 +74,7 @@ export default function useHtmlTableCellProps({ maxY, tableBorderStyle, resolvedCellStyle - } = propsFromParent as InternalTableCellPropsFromParent; + } = propsFromParent; const styleFromConfig = resolvedCellStyle ? resolvedCellStyle.configStyle : resolveConfiguredCellStyle(config?.getStyleForCell?.call(null, cell)); @@ -72,7 +100,7 @@ export default function useHtmlTableCellProps({ : borderCollapse ? getCollapsedCellBorderStyle( cell, - composeCellStyle(props.tnode.styles.nativeBlockRet, styleFromConfig), + composeCellStyle(getSourceBlockStyle(props.tnode), styleFromConfig), { maxX, maxY, tableBorderStyle } ) : null; @@ -84,7 +112,7 @@ export default function useHtmlTableCellProps({ { border: collapsedBorderStyle, rendererDefaults: { flexGrow: 1, flexShrink: 0, ...alignmentStyles }, - paddingSource: props.tnode.styles.nativeBlockRet + paddingSource: getSourceBlockStyle(props.tnode) } ), width: cell.width, From 621678eb97f623c74c51d91506121bbc40869a40 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 21:34:25 +0200 Subject: [PATCH 18/21] refactor(heuristic-table-plugin): separate the table grid from config and drop dead weight --- packages/heuristic-table-plugin/README.md | 32 +++-- .../heuristic-table-plugin/jest.config.js | 2 +- packages/heuristic-table-plugin/package.json | 14 +-- .../heuristic-table-plugin/src/HTMLTable.tsx | 2 +- .../heuristic-table-plugin/src/TableLayout.ts | 54 ++++---- .../src/TableRenderer.ts | 7 -- .../src/__tests__/TableLayout.test.ts | 36 +++--- .../src/__tests__/borderSpacing.test.tsx | 2 +- .../src/__tests__/layoutStyles.test.ts | 6 +- .../src/__tests__/tsconfig.json | 9 -- .../src/helpers/TCellConstraintsComputer.ts | 6 +- ...Display.test.ts => buildTableGrid.test.ts} | 27 +--- .../__tests__/computeColumnWidths.test.ts | 29 +++-- .../__tests__/createRenderTree.test.ts | 10 +- .../__tests__/reduceColumnConstraints.test.ts | 106 +++++++++++----- .../src/helpers/__tests__/tableStyles.test.ts | 5 +- .../src/helpers/__tests__/tsconfig.json | 9 -- .../src/helpers/buildTableGrid.ts | 117 +++++++++++++++++ .../src/helpers/computeColumnWidths.ts | 100 +++++++-------- .../src/helpers/createRenderTree.ts | 6 +- .../src/helpers/fillTableDisplay.ts | 119 ------------------ .../src/helpers/measure.ts | 7 +- .../src/helpers/reduceColumnConstraints.ts | 91 ++++++++++---- .../src/helpers/resolveTableStyles.ts | 18 +-- .../src/helpers/tableStyles.ts | 12 +- packages/heuristic-table-plugin/src/index.ts | 4 +- .../src/shared-types.ts | 37 +++++- .../src/useHtmlTableCellProps.ts | 6 +- .../src/useHtmlTableProps.ts | 6 + yarn.lock | 12 +- 30 files changed, 495 insertions(+), 396 deletions(-) delete mode 100644 packages/heuristic-table-plugin/src/__tests__/tsconfig.json rename packages/heuristic-table-plugin/src/helpers/__tests__/{fillTableDisplay.test.ts => buildTableGrid.test.ts} (89%) delete mode 100644 packages/heuristic-table-plugin/src/helpers/__tests__/tsconfig.json create mode 100644 packages/heuristic-table-plugin/src/helpers/buildTableGrid.ts delete mode 100644 packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts diff --git a/packages/heuristic-table-plugin/README.md b/packages/heuristic-table-plugin/README.md index 98f0eb4..cfc60e3 100644 --- a/packages/heuristic-table-plugin/README.md +++ b/packages/heuristic-table-plugin/README.md @@ -74,7 +74,6 @@ const html = ` `; const htmlProps = { - WebView, renderers: { ...tableRenderers }, @@ -102,6 +101,18 @@ to the `renderersProps.table` prop of `RenderHTML` component. See the documentation for this object here: [`HeuristicTablePluginConfig`](docs/heuristic-table-plugin.heuristictablepluginconfig.md) +| Option | Default | What it does | +| --- | --- | --- | +| `forceStretch` | `true` | Whether an auto-width table fills its containing block, or shrinks to fit its content. | +| `growBeyondHeight` | `false` | Whether a declared table `height` is a minimum the table may grow past, or a fixed viewport that scrolls. | +| `borderCollapse` | from the markup | Overrides the table's border model. When omitted, an inline `border-collapse` (or a `rules` attribute) decides. | +| `baseFontCoeff` | `0.65` | The average character width, as a fraction of the font size. Text is estimated, never measured — raise it if tables come out too narrow, lower it if cells claim more width than their content needs. | +| `fontWeightCoeffs` | see below | How much wider text renders per font weight, keyed by the stringified `fontWeight`. Merged over the defaults, so `{ bold: 1.05 }` retunes bold alone. | +| `getStyleForCell` | — | Returns extra styles per cell. Called once per cell per layout, against provisional widths. | + +Pass `fontWeightCoeffs` and `getStyleForCell` as referentially stable values: a +fresh literal on every render relays out every table using it. + ### Cell padding As in HTML, where the user-agent stylesheet declares `td, th { padding: 1px }`, @@ -143,9 +154,11 @@ import React from 'react'; import tableRenderers, {useHtmlTableProps, HTMLTable} from '@native-html/heuristic-table-plugin'; function TableRenderer(props) { - const tableProps = useHtmlTableProps(props, /* config */); + const tableProps = useHtmlTableProps(props); + // Table options come from `renderersProps.table`, not from this hook. + // Its optional second argument is `{ overrideContentWidth }` alone. // Do customize the props here; wrap with your own container... - return ; + return ; }; const renderers = { @@ -158,8 +171,8 @@ const renderers = { ### Customizing Th and Td renderers -You can customize cell rendering via `useHtmlTableCellProps`, `thModel` and -`tdModel` exports. This renderer will receive a special `propsFromParent` of +You can customize cell rendering via the `useHtmlTableCellProps` hook. Such a +renderer receives a special `propsFromParent` of type [`TableCellPropsFromParent`](docs/heuristic-table-plugin.tablecellpropsfromparent.md). You can take advantage of this information to customize depending on the @@ -170,8 +183,7 @@ import React from 'react'; import { TableRenderer, ThRenderer, - useHtmlTableCellProps, - tdModel + useHtmlTableCellProps } from '@native-html/heuristic-table-plugin'; function TdRenderer(props) { @@ -179,12 +191,12 @@ function TdRenderer(props) { // The cell parent prop contains information about this cell, // especially its position (x, y) and lengths (lenX, lenY). // In this example, we customize the background depending on the - // y coordinate (row index). + // x coordinate (column index). const { cell } = cellProps.propsFromParent; const style = [ cellProps.style, - backgroundColor: cell.x % 2 === 0 ? 'lightgray' : 'white' - ] + { backgroundColor: cell.x % 2 === 0 ? 'lightgray' : 'white' } + ]; return React.createElement(cellProps.TDefaultRenderer, { ...cellProps, style }); } diff --git a/packages/heuristic-table-plugin/jest.config.js b/packages/heuristic-table-plugin/jest.config.js index cf5824c..2b3dc18 100644 --- a/packages/heuristic-table-plugin/jest.config.js +++ b/packages/heuristic-table-plugin/jest.config.js @@ -4,6 +4,6 @@ module.exports = { testRegex: 'src/.*\\.test\\.tsx?$', coveragePathIgnorePatterns: ['/node_modules/', '__tests__'], transformIgnorePatterns: [ - 'node_modules/(?!(@react-native|react-native|ramda|@native-html|stringify-entities|character-entities-html4|character-entities-legacy)/)' + 'node_modules/(?!(@react-native|react-native|@native-html|stringify-entities|character-entities-html4|character-entities-legacy)/)' ] }; diff --git a/packages/heuristic-table-plugin/package.json b/packages/heuristic-table-plugin/package.json index 7a57944..61ece4b 100644 --- a/packages/heuristic-table-plugin/package.json +++ b/packages/heuristic-table-plugin/package.json @@ -31,36 +31,30 @@ "plugins" ], "devDependencies": { - "@babel/cli": "^7.28.6", "@babel/core": "^7.29.0", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/preset-typescript": "^7.28.5", "@babel/runtime": "^7.28.6", "@microsoft/api-documenter": "^7.29.6", "@microsoft/api-extractor": "7.57.6", "@native-html/render": "1.0.0-alpha.0", - "@testing-library/react": "16.3.2", + "@native-html/transient-render-engine": "12.0.0-alpha.0", "@testing-library/react-native": "^13.3.3", "@tsconfig/react-native": "^3.0.9", - "@types/html-validator": "^5.0.6", "@types/jest": "^30.0.0", "@types/react": "^19.2.14", - "@types/react-native": "^0.73.0", "babel-jest": "^30.2.0", - "babel-plugin-inline-import": "^3.0.0", + "babel-plugin-syntax-hermes-parser": "^0.32.0", "eslint": "^10.0.2", "jest": "^30.2.0", "metro-react-native-babel-preset": "^0.77.0", - "metro-react-native-babel-transformer": "^0.77.0", "react": "19.2.0", "react-native": "0.83.2", "react-native-builder-bob": "^0.40.18", "typescript": "~5.8.2" }, - "dependencies": { - "@types/prop-types": "^15.7.15", - "prop-types": "^15.8.1" - }, "peerDependencies": { "@native-html/render": ">=1.0.0-alpha.0", "react": ">= 16.8.0", diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index 4a3fd93..145f9f6 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -119,7 +119,7 @@ const HTMLTable = memo(function HTMLTable({ > sum(columnWidths)) columnWidths = raised; @@ -191,7 +199,7 @@ export default class TableLayout { this.horizontalInsets = measured.insets; this.availableWidth = availableWidth; this.usedWidth = Math.max(0, Math.min(usedTableWidth, availableWidth)); - this.assignableWidth = Math.max(0, this.usedWidth - measured.insets); + this.viewportWidth = Math.max(0, this.usedWidth - measured.insets); this.display = display; this.columnWidths = measured.columnWidths; this.totalWidth = sum(this.columnWidths) + spacingWidth; diff --git a/packages/heuristic-table-plugin/src/TableRenderer.ts b/packages/heuristic-table-plugin/src/TableRenderer.ts index 3d94669..fedba31 100644 --- a/packages/heuristic-table-plugin/src/TableRenderer.ts +++ b/packages/heuristic-table-plugin/src/TableRenderer.ts @@ -1,15 +1,8 @@ import React from 'react'; import { CustomBlockRenderer } from '@native-html/render'; import HTMLTable from './HTMLTable'; -import { HeuristicTablePluginConfig } from './shared-types'; import useHtmlTableProps from './useHtmlTableProps'; -declare module '@native-html/render' { - interface RenderersPropsBase { - table?: HeuristicTablePluginConfig; - } -} - /** * A 100% native renderer component for `table` tag. * diff --git a/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts b/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts index 0d9e822..f9adcbf 100644 --- a/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts @@ -465,12 +465,12 @@ describe('TableLayout', () => { const rows = ''; it('should lay out against the width left by a padded ancestor', () => { - const { assignableWidth, availableWidth, totalWidth } = layoutFor( + const { viewportWidth, availableWidth, totalWidth } = layoutFor( `
alphabeta
${rows}
`, { contentWidth: 400, forceStretch: true } ); expect(availableWidth).toBe(340); - expect(assignableWidth).toBe(340); + expect(viewportWidth).toBe(340); expect(totalWidth).toBeCloseTo(340); }); @@ -485,12 +485,12 @@ describe('TableLayout', () => { it('should keep the columns inside the table own padding and border', () => { // `width` is a border box in React Native, so padding and border eat into // the space the columns may use rather than adding to the table width. - const { assignableWidth, availableWidth, totalWidth } = layoutFor( + const { viewportWidth, availableWidth, totalWidth } = layoutFor( `${rows}
`, { contentWidth: 400, forceStretch: true } ); expect(availableWidth).toBe(400); - expect(assignableWidth).toBe(378); + expect(viewportWidth).toBe(378); expect(totalWidth).toBeCloseTo(378); }); @@ -500,33 +500,33 @@ describe('TableLayout', () => { // block while the table box is allowed only what is left of it, and the // difference would surface as a scroller the very same table without a // declared width never gets. - const { totalWidth, assignableWidth } = layoutFor( + const { totalWidth, viewportWidth } = layoutFor( `${rows}
`, { contentWidth: 400, forceStretch: true } ); - expect(assignableWidth).toBe(380); + expect(viewportWidth).toBe(380); expect(totalWidth).toBeCloseTo(380); - expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(false); + expect(shouldScrollTable(totalWidth, viewportWidth)).toBe(false); }); it('should still scroll an absolute width wider than the container', () => { // Clamping the columns to the available width instead would silently // drop the width the table asked for. - const { totalWidth, assignableWidth } = layoutFor( + const { totalWidth, viewportWidth } = layoutFor( `${rows}
`, { contentWidth: 400 } ); expect(totalWidth).toBeCloseTo(800); - expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(true); + expect(shouldScrollTable(totalWidth, viewportWidth)).toBe(true); }); it('should take the table own margins out of the width it may occupy', () => { - const { assignableWidth, availableWidth } = layoutFor( + const { viewportWidth, availableWidth } = layoutFor( `${rows}
`, { contentWidth: 400, forceStretch: true } ); expect(availableWidth).toBe(350); - expect(assignableWidth).toBe(350); + expect(viewportWidth).toBe(350); }); it('should stretch to the available width by default', () => { @@ -606,28 +606,28 @@ describe('TableLayout', () => { it('should report the column overflow beyond the table max-width', () => { // The cells demand 600px inside a table that paints only 300px, so the // surplus belongs to a horizontal scroller rather than spilling out. - const { totalWidth, assignableWidth } = layoutFor( + const { totalWidth, viewportWidth } = layoutFor( `
AB
`, { contentWidth: 600, forceStretch: false } ); - expect(assignableWidth).toBe(300); + expect(viewportWidth).toBe(300); expect(totalWidth).toBeGreaterThanOrEqual(600); - expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(true); + expect(shouldScrollTable(totalWidth, viewportWidth)).toBe(true); }); it('should cap usedWidth at the containing width when padding overflows', () => { // The insets were added back after the assignable width had been // floored at zero, so a table whose padding alone overflows its // container painted a box wider than the room it was given. - const { usedWidth, assignableWidth } = layoutFor( + const { usedWidth, viewportWidth } = layoutFor( `
A
`, { contentWidth: 400, forceStretch: true } ); - expect(assignableWidth).toBe(0); + expect(viewportWidth).toBe(0); expect(usedWidth).toBe(30); }); @@ -648,13 +648,13 @@ describe('TableLayout', () => { }); it('should still overflow when the minimum widths do not fit', () => { - const { totalWidth, assignableWidth } = layoutFor( + const { totalWidth, viewportWidth } = layoutFor( `
AB
`, { contentWidth: 400, forceStretch: true } ); - expect(assignableWidth).toBe(300); + expect(viewportWidth).toBe(300); expect(totalWidth).toBeGreaterThanOrEqual(600); }); }); diff --git a/packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx b/packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx index ac4b105..45b1313 100644 --- a/packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx +++ b/packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx @@ -75,7 +75,7 @@ describe('border spacing', () => { ); const layout = new TableLayout(table, { contentWidth: 300 }); expect(layout.totalWidth).toBe(424); - expect(layout.assignableWidth).toBe(300); + expect(layout.viewportWidth).toBe(300); }); it('paints gaps outside cells and only once around the grid', () => { diff --git a/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts b/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts index 69a16f7..91024d6 100644 --- a/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts @@ -236,7 +236,7 @@ describe('layout and resolved renderer styles', () => { ); expect(layout.horizontalInsets).toBe(20); expect(layout.totalWidth).toBeCloseTo(9.1); - expect(shouldScrollTable(layout.totalWidth, layout.assignableWidth)).toBe( + expect(shouldScrollTable(layout.totalWidth, layout.viewportWidth)).toBe( false ); expect(renderedCellStyle(layout, 0)).toMatchObject({ @@ -382,7 +382,7 @@ describe('layout and resolved renderer styles', () => { contentWidth: 20, getStyleForCell: () => ({ padding: 8 }) }); - expect(shouldScrollTable(layout.totalWidth, layout.assignableWidth)).toBe( + expect(shouldScrollTable(layout.totalWidth, layout.viewportWidth)).toBe( true ); }); @@ -408,7 +408,7 @@ describe('cell percentage distribution', () => { '
AAAAAAAAAAAAB
' ); expect(layout.columnWidths[0]).toBeCloseTo(12 * 9.1 + 2); - expect(shouldScrollTable(layout.totalWidth, layout.assignableWidth)).toBe( + expect(shouldScrollTable(layout.totalWidth, layout.viewportWidth)).toBe( true ); }); diff --git a/packages/heuristic-table-plugin/src/__tests__/tsconfig.json b/packages/heuristic-table-plugin/src/__tests__/tsconfig.json deleted file mode 100644 index c8a11dd..0000000 --- a/packages/heuristic-table-plugin/src/__tests__/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../../tsconfig-base.json", - "compilerOptions": { - "types": ["jest"], - "noEmit": true, - "ignoreDeprecations": "6.0" - }, - "exclude": ["../../node_modules", "../../lib"] -} diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index f9f9f59..b9c56dd 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -4,6 +4,7 @@ import { TCellConstraints } from '../shared-types'; import { getHorizontalInsets, getHorizontalMargins } from './measure'; import { getPaintedBlockStyle } from './tableStyles'; import { + clampWidth, resolveCssSize, resolveImposedWidth, resolvePercentage @@ -292,10 +293,7 @@ export default class TCellConstraintsComputer { // `max-width` caps the width the cell would *like*, but never takes it // below the width it needs to hold its longest word: min-content is a // floor no browser crosses. - maxWidth: - cellMaxWidth === null - ? maxWidth - : Math.max(minWidth, Math.min(maxWidth, cellMaxWidth)), + maxWidth: clampWidth(maxWidth, minWidth, cellMaxWidth), contentDensity: intrinsic.contentDensity }; } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/buildTableGrid.test.ts similarity index 89% rename from packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts rename to packages/heuristic-table-plugin/src/helpers/__tests__/buildTableGrid.test.ts index 4a531c9..cad52d6 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/fillTableDisplay.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/buildTableGrid.test.ts @@ -1,15 +1,12 @@ -import { TNode } from '@native-html/render'; -import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; -import TCellConstraintsComputer from '../TCellConstraintsComputer'; +import buildTableGrid from '../buildTableGrid'; import { createTableTNode } from '../../__tests__/utils'; -function createDisplay(tnode: TNode) { - const display = createEmptyDisplay({ contentWidth: 1000 }); - fillTableDisplay(tnode, display, new TCellConstraintsComputer({})); - return display; -} +const createDisplay = buildTableGrid; -describe('fillTableDisplay', () => { +// The slot cursor and the occupancy index are local to the build, so what a +// spanning cell blocks is asserted where it shows: the coordinates the cells +// after it are given. +describe('buildTableGrid', () => { it('should parse cells', () => { const table = ` @@ -68,9 +65,7 @@ describe('fillTableDisplay', () => {
`; const tnode = createTableTNode(table); const display = createDisplay(tnode); - // `offsetX` is the slot cursor of the row last laid out, so it ends up // just past that row's final cell. - expect(display.offsetX).toBe(1); expect(display.maxX).toBe(3); expect(display.maxY).toBe(1); expect(display.cells).toMatchObject([ @@ -123,8 +118,6 @@ describe('fillTableDisplay', () => { const display = createDisplay(tnode); expect(display.maxX).toBe(2); expect(display.maxY).toBe(1); - expect(display.offsetX).toBe(3); - expect(display.occupiedCoordinates).toMatchObject([{ x: 0, y: 1 }]); expect(display.cells).toMatchObject([ { lenX: 1, @@ -175,8 +168,6 @@ describe('fillTableDisplay', () => { const display = createDisplay(tnode); expect(display.maxX).toBe(2); expect(display.maxY).toBe(1); - expect(display.offsetX).toBe(3); - expect(display.occupiedCoordinates).toMatchObject([{ x: 1, y: 1 }]); expect(display.cells).toMatchObject([ { lenX: 1, @@ -227,11 +218,6 @@ describe('fillTableDisplay', () => { const display = createDisplay(tnode); expect(display.maxX).toBe(2); expect(display.maxY).toBe(1); - // expect(display.offsetX).toBe(2); - expect(display.occupiedCoordinates).toMatchObject([ - { x: 0, y: 1 }, - { x: 2, y: 1 } - ]); expect(display.cells).toMatchObject([ { lenX: 1, @@ -280,7 +266,6 @@ describe('fillTableDisplay', () => { const display = createDisplay(tnode); expect(display.maxX).toBe(2); expect(display.maxY).toBe(2); - expect(display.offsetX).toBe(1); expect(display.cells).toMatchObject([ { lenX: 1, diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts index 8f755e6..e642f3f 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts @@ -1,21 +1,24 @@ -import computeColumnWidths from '../computeColumnWidths'; -import { createEmptyDisplay } from '../fillTableDisplay'; -import { Display, DisplayCell, TCellConstraints } from '../../shared-types'; +import computeColumnWidths, { + ColumnLayoutInput +} from '../computeColumnWidths'; +import { DisplayCell, TCellConstraints } from '../../shared-types'; function makeDisplay( cells: Array< Pick & { constraints: TCellConstraints } >, - settings: { contentWidth: number; forceStretch?: boolean } -): Display { - const display = createEmptyDisplay(settings); - display.cells = cells.map((cell) => ({ - lenX: 1, - lenY: 1, - tnode: null as never, - ...cell - })); - return display; + { contentWidth, forceStretch }: { contentWidth: number; forceStretch?: boolean } +): ColumnLayoutInput { + return { + assignableWidth: contentWidth, + forceStretch, + cells: cells.map((cell) => ({ + lenX: 1, + lenY: 1, + tnode: null as never, + ...cell + })) + }; } describe('computeColumnWidths', () => { diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts index d301497..518c3e2 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts @@ -1,7 +1,6 @@ -import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; +import buildTableGrid from '../buildTableGrid'; import { createTableTNode } from '../../__tests__/utils'; import createRenderTree, { makeTableCells } from '../createRenderTree'; -import TCellConstraintsComputer from '../TCellConstraintsComputer'; import { TableCell, TableFlexColumnContainer, @@ -11,10 +10,9 @@ import { function makeRenderTree(html: string, columnWidths: number[]) { const tnode = createTableTNode(html); - const display = createEmptyDisplay({ contentWidth: 1000 }); - const computer = new TCellConstraintsComputer({}); - fillTableDisplay(tnode, display, computer); - return createRenderTree(makeTableCells(display, columnWidths)); + // The render tree is built from coordinates and widths alone, so the grid + // needs no measurement pass to produce one. + return createRenderTree(makeTableCells(buildTableGrid(tnode), columnWidths)); } function rowContainer( diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts index 8296625..500c016 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/reduceColumnConstraints.test.ts @@ -1,4 +1,12 @@ import reduceColumnConstraints from '../reduceColumnConstraints'; +import { TColumnConstraints } from '../../shared-types'; + +/** A column reduced from cells that declare no spacing and no percentage. */ +function bare( + constraints: Omit +): TColumnConstraints { + return { horizontalSpace: 0, percentWidth: null, ...constraints }; +} describe('reduceColumnConstraints', () => { it('should raise a maximum below its minimum to the minimum', () => { @@ -12,7 +20,7 @@ describe('reduceColumnConstraints', () => { constraints: { minWidth: 51, maxWidth: 30, contentDensity: 10 } } ]) - ).toEqual([{ minWidth: 51, spread: 51, contentDensity: 10 }]); + ).toEqual([bare({ minWidth: 51, spread: 51, contentDensity: 10 })]); }); it('should return a record which keys are column indexes, and which values are the reduced constraints for this column', () => { @@ -64,16 +72,8 @@ describe('reduceColumnConstraints', () => { } ]) ).toEqual([ - { - contentDensity: 6, - spread: 3, - minWidth: 2 - }, - { - contentDensity: 6, - spread: 4, - minWidth: 3 - } + bare({ contentDensity: 6, spread: 3, minWidth: 2 }), + bare({ contentDensity: 6, spread: 4, minWidth: 3 }) ]); }); it('should split content density and min width of cells expanding horizontaly by its length when reducing constraints', () => { @@ -103,21 +103,9 @@ describe('reduceColumnConstraints', () => { } ]) ).toEqual([ - { - contentDensity: 7, - spread: 4, - minWidth: 2 - }, - { - contentDensity: 3, - spread: 3, - minWidth: 1 - }, - { - contentDensity: 3, - spread: 3, - minWidth: 1 - } + bare({ contentDensity: 7, spread: 4, minWidth: 2 }), + bare({ contentDensity: 3, spread: 3, minWidth: 1 }), + bare({ contentDensity: 3, spread: 3, minWidth: 1 }) ]); }); it('should keep a slot for a column no cell occupies', () => { @@ -142,9 +130,69 @@ describe('reduceColumnConstraints', () => { } ]) ).toEqual([ - { contentDensity: 3, spread: 3, minWidth: 2 }, - { contentDensity: 0, spread: 0, minWidth: 0 }, - { contentDensity: 5, spread: 5, minWidth: 4 } + bare({ contentDensity: 3, spread: 3, minWidth: 2 }), + bare({ contentDensity: 0, spread: 0, minWidth: 0 }), + bare({ contentDensity: 5, spread: 5, minWidth: 4 }) + ]); + }); + + it('spreads a colspan cell spacing and percentage over its columns', () => { + expect( + reduceColumnConstraints([ + { + lenX: 2, + lenY: 1, + x: 0, + y: 0, + constraints: { + contentDensity: 8, + maxWidth: 8, + minWidth: 4, + horizontalSpace: 6, + percentWidth: 0.5 + } + } + ]) + ).toEqual([ + { + contentDensity: 4, + spread: 4, + minWidth: 2, + horizontalSpace: 3, + percentWidth: 0.25 + }, + { + contentDensity: 4, + spread: 4, + minWidth: 2, + horizontalSpace: 3, + percentWidth: 0.25 + } + ]); + }); + + it('takes the widest spacing and largest percentage where cells disagree', () => { + const cell = (y: number, horizontalSpace: number, percentWidth: number) => ({ + lenX: 1, + lenY: 1, + x: 0, + y, + constraints: { + contentDensity: 1, + maxWidth: 1, + minWidth: 1, + horizontalSpace, + percentWidth + } + }); + expect(reduceColumnConstraints([cell(0, 2, 0.1), cell(1, 9, 0.4)])).toEqual([ + { + contentDensity: 2, + spread: 1, + minWidth: 1, + horizontalSpace: 9, + percentWidth: 0.4 + } ]); }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts index 4decf72..492369a 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts @@ -6,7 +6,7 @@ import { resolveBorderCollapse, resolveCellVerticalAlign } from '../tableStyles'; -import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay'; +import buildTableGrid from '../buildTableGrid'; import { createCellTNode, createTableTNode } from '../../__tests__/utils'; /** A wrapper that paints all four of its resolved outer edges. */ @@ -19,8 +19,7 @@ const FRAMED = { function displayFor(html: string) { const table = createTableTNode(html); - const display = createEmptyDisplay({ contentWidth: 400 }); - fillTableDisplay(table, display); + const display = buildTableGrid(table); return { display, table }; } diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tsconfig.json b/packages/heuristic-table-plugin/src/helpers/__tests__/tsconfig.json deleted file mode 100644 index faf20aa..0000000 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../../../tsconfig-base.json", - "compilerOptions": { - "types": ["jest"], - "noEmit": true, - "ignoreDeprecations": "6.0" - }, - "exclude": ["../../../node_modules", "../../../lib"] -} diff --git a/packages/heuristic-table-plugin/src/helpers/buildTableGrid.ts b/packages/heuristic-table-plugin/src/helpers/buildTableGrid.ts new file mode 100644 index 0000000..38f0304 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/buildTableGrid.ts @@ -0,0 +1,117 @@ +import { TNode } from '@native-html/render'; +import { DisplayCell, TableGrid, TCellConstraints } from '../shared-types'; +import parseSpan, { MAX_COLSPAN, MAX_ROWSPAN } from './parseSpan'; + +/** + * The constraints of a cell no computer has measured yet. + * + * @remarks + * The grid is built before the width its cells must be measured against is + * known — coordinates and spans do not depend on it, whereas constraints do, + * and measuring text is the costly half of a layout pass. Every cell carries + * this placeholder until {@link TableLayout}'s measurement pass replaces it. + */ +const UNMEASURED_CONSTRAINTS: TCellConstraints = Object.freeze({ + contentDensity: 0, + maxWidth: 0, + minWidth: 0 +}); + +/** + * The state of the walk that lays cells down, live only while building. + * + * @remarks + * Kept out of {@link TableGrid}: `offsetX` is meaningless once the last row is + * placed, and `occupied` is an index nothing downstream reads. Both used to be + * carried on the result, where the grid's consumers had to look past them. + */ +interface GridCursor { + /** + * The slot cursor for the current row. Cells are laid down left to right + * from wherever the previous one ended, skipping any slot a spanning cell + * from an earlier row has claimed. Deriving the column from `nodeIndex` + * instead would let a stray non-cell element inside the row shift every + * following cell. + */ + offsetX: number; + /** + * Slots claimed by a cell spanning down from an earlier row, keyed `x,y`. + * + * @remarks + * A set rather than a list: this is probed once per slot per cell while + * scanning for a free column, so a linear scan makes filling a table with + * row spans quadratic in its cell count. + */ + occupied: Set; +} + +function isOccupied(cursor: GridCursor, x: number, y: number): boolean { + return cursor.occupied.has(`${x},${y}`); +} + +/** + * Find the first slot in row `y` at or after `fromX` that no spanning cell has + * already claimed. + * + * @remarks + * The search must advance one slot at a time: counting blockers in a single + * pass can land the cell on another blocked slot, so two cells end up sharing + * one coordinate. + */ +function findFreeSlotX(cursor: GridCursor, fromX: number, y: number): number { + let x = fromX; + while (isOccupied(cursor, x, y)) { + x += 1; + } + return x; +} + +function fill(tnode: TNode, grid: TableGrid, cursor: GridCursor) { + if (tnode.tagName === 'tr') { + grid.maxY = grid.maxY + 1; + cursor.offsetX = 0; + } + if (tnode.tagName !== 'th' && tnode.tagName !== 'td') { + tnode.children.forEach((child) => fill(child, grid, cursor)); + return; + } + const lenX = parseSpan(tnode.attributes.colspan, MAX_COLSPAN); + const lenY = parseSpan(tnode.attributes.rowspan, MAX_ROWSPAN); + const startY = grid.maxY; + const startX = findFreeSlotX(cursor, cursor.offsetX, startY); + const cell: DisplayCell = { + lenX, + lenY, + x: startX, + y: startY, + tnode, + constraints: UNMEASURED_CONSTRAINTS + }; + grid.cells.push(cell); + cursor.offsetX = startX + lenX; + if (lenY > 1) { + // A spanning cell claims the whole rectangle it covers, so a cell that is + // both `colspan` and `rowspan` blocks every column it straddles in each of + // the rows below — not just its first one. + for (let y = startY + 1; y < lenY + startY; y++) { + for (let x = startX; x < startX + lenX; x++) { + cursor.occupied.add(`${x},${y}`); + } + } + } + grid.maxX = Math.max(grid.maxX, startX + lenX - 1); +} + +/** + * Lay every `th` and `td` of a table out on its matrix. + * + * @returns Where each cell sits and how far the matrix extends. Cell + * constraints are left {@link UNMEASURED_CONSTRAINTS} — measuring them needs + * the resolved cell styles and the table's content width, neither of which + * exists yet. + */ +export default function buildTableGrid(tnode: TNode): TableGrid { + const grid: TableGrid = { cells: [], maxX: -1, maxY: -1 }; + fill(tnode, grid, { offsetX: 0, occupied: new Set() }); + return grid; +} diff --git a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts index 6dc5dc1..8ee330a 100644 --- a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts @@ -1,4 +1,4 @@ -import { Display, TColumnConstraints } from '../shared-types'; +import { DisplayCell, TColumnConstraints } from '../shared-types'; import reduceColumnConstraints from './reduceColumnConstraints'; import type { DeclaredColumnWidth } from './extractColumnWidths'; import { clampWidth, lesserBound } from './resolveWidth'; @@ -133,45 +133,62 @@ function addDistributedWidth( return result; } +/** + * What deciding column widths actually depends on. + * + * @remarks + * Stated explicitly rather than taken from the whole layout: `assignableWidth` + * is the width left for columns after the table's own padding, border and + * border-spacing, which is a different quantity from the document width the + * config calls `contentWidth`, and passing the latter by mistake is otherwise + * invisible. + */ +export interface ColumnLayoutInput { + cells: readonly DisplayCell[]; + /** The width the columns may share between them. */ + assignableWidth: number; + /** Whether the columns must fill that width rather than shrink to fit. */ + forceStretch?: boolean; +} + export default function computeColumnWidths( - display: Display, + { cells, assignableWidth, forceStretch }: ColumnLayoutInput, declaredWidths: Array = [] ): number[] { - const contentWidth = display.contentWidth; - const shouldStretch = !!display.forceStretch; + const contentWidth = assignableWidth; + const shouldStretch = !!forceStretch; // The cell grid alone decides how many columns a table has. `col` and // `colgroup` declarations past its last column describe columns that do not // exist — honouring them would widen the table by the sum of widths nothing // is ever rendered into, and hand it a scroll view to hold the surplus. - const columnConstraints = reduceColumnConstraints(display.cells); + const columnConstraints = reduceColumnConstraints([...cells]); if (columnConstraints.length === 0) { return []; } - // Cell percentages use the same sizing class as col/colgroup percentages. - // Repeated rows contribute a maximum, not a sum. A colspan shares its - // preference across the columns it covers, like its intrinsic constraints. - declaredWidths = [...declaredWidths]; - for (const cell of display.cells) { - const percent = cell.constraints.percentWidth; - if (percent == null) continue; - for (let i = cell.x; i < cell.x + cell.lenX; i++) { - const declared = declaredWidths[i]; - declaredWidths[i] = { - width: null, - minWidth: 0, - maxWidth: null, - maxPercent: null, - ...declared, - percent: Math.max(declared?.percent ?? 0, percent / cell.lenX) - }; + // Cell percentages use the same sizing class as col/colgroup percentages, + // and `reduceColumnConstraints` has already spread each spanning cell's + // preference over the columns it covers. + const declarations = columnConstraints.map((constraints, i) => { + const declared = declaredWidths[i]; + const percent = constraints.percentWidth; + if (percent == null) { + return declared ?? null; } - } + return { + width: null, + minWidth: 0, + maxWidth: null, + maxPercent: null, + ...declared, + percent: Math.max(declared?.percent ?? 0, percent) + }; + }); // A `max-width` may be declared in either unit, and caps the column in // whichever sizing class it ends up in. Percentage bounds travel unresolved // so that the same declarations can be reused against another table width, // and are turned into pixels here, once that width is known. const caps = columnConstraints.map((_, i) => { - const declared = declaredWidths[i]; + const declared = declarations[i]; if (!declared) { return null; } @@ -181,7 +198,7 @@ export default function computeColumnWidths( ); }); for (const [i, constraints] of columnConstraints.entries()) { - const declared = declaredWidths[i]; + const declared = declarations[i]; if (!declared) { continue; } @@ -198,14 +215,9 @@ export default function computeColumnWidths( constraints.minWidth = Math.max(constraints.minWidth, floor); constraints.spread = Math.max(constraints.spread, floor); } - if (cap !== null) { - // A `max-width` caps how far a column may grow, but never below the - // width its own content needs to be legible at all. - constraints.spread = Math.max( - constraints.minWidth, - Math.min(constraints.spread, cap) - ); - } + // A `max-width` caps how far a column may grow, but never below the + // width its own content needs to be legible at all. + constraints.spread = clampWidth(constraints.spread, constraints.minWidth, cap); } const minWidths = mapMinWidths(columnConstraints); const spreads = mapSpreads(columnConstraints); @@ -221,7 +233,7 @@ export default function computeColumnWidths( // the full percentage guess does not fit, browsers interpolate back toward // the min-content guess while keeping the total at the assignable width. const percentages = normalizePercentages( - declaredWidths, + declarations, columnConstraints.length ); const percentageGuess = minWidths.map((minWidth, i) => { @@ -261,26 +273,14 @@ export default function computeColumnWidths( return maxContentGuess; } // The spacing each column carries, so that the surplus below is shared over - // content alone. A `colspan` spreads its own spacing across the columns it - // covers, as it does its intrinsic constraints, and where cells disagree the - // widest wins — the same reduction `minWidth` gets, which is the figure the - // spacing is being held out of. - const columnInsets = columnConstraints.map(() => 0); - for (const cell of display.cells) { - const share = (cell.constraints.horizontalSpace ?? 0) / cell.lenX; - const lastColumn = Math.min( - cell.x + cell.lenX - 1, - columnInsets.length - 1 - ); - for (let i = cell.x; i <= lastColumn; i++) { - columnInsets[i] = Math.max(columnInsets[i] ?? 0, share); - } - } + // content alone; `reduceColumnConstraints` reduced it the same way it + // reduced `minWidth`, which is the figure the spacing is held out of. + const columnInsets = columnConstraints.map((c) => c.horizontalSpace); const allColumns = maxContentGuess.map((_, i) => i); // A column that declared a width of its own already has the width it asked // for; the surplus belongs to the ones that left it to the table to decide. const autoColumns = allColumns.filter((i) => { - const declared = declaredWidths[i]; + const declared = declarations[i]; return !declared || (declared.width === null && declared.percent === null); }); const percentColumns = allColumns.filter((i) => percentages[i] != null); diff --git a/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts b/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts index 24c6f31..8353d80 100644 --- a/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts +++ b/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts @@ -1,6 +1,6 @@ import { TableCell, - Display, + TableGrid, DisplayCell, TableFlexColumnContainer, TableFlexRowContainer, @@ -112,11 +112,11 @@ function makeCell( * only becomes a {@link TableCell} once its width exists. */ export function makeTableCells( - display: Pick, + grid: Pick, columnWidths: number[], spacing = 0 ): TableCell[] { - return display.cells.map((cell) => makeCell(columnWidths, cell, spacing)); + return grid.cells.map((cell) => makeCell(columnWidths, cell, spacing)); } export default function createRenderTree(cells: TableCell[]): TableRoot { diff --git a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts b/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts deleted file mode 100644 index eb86971..0000000 --- a/packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { TNode } from '@native-html/render'; -import { - Display, - DisplayCell, - Settings, - TCellConstraints -} from '../shared-types'; -import TCellConstraintsComputer from './TCellConstraintsComputer'; -import parseSpan, { MAX_COLSPAN, MAX_ROWSPAN } from './parseSpan'; - -/** - * The constraints of a cell no computer has measured yet. - * - * @remarks - * {@link fillTableDisplay} may be called without a computer, to lay the grid - * out before the width its cells must be measured against is known. Every cell - * of such a display carries this placeholder until the caller's measurement - * pass replaces it. - */ -const UNMEASURED_CONSTRAINTS: TCellConstraints = Object.freeze({ - contentDensity: 0, - maxWidth: 0, - minWidth: 0 -}); - -export function createEmptyDisplay(config: Settings): Display { - return { - offsetX: 0, - occupiedCoordinates: [], - maxY: -1, - maxX: -1, - cells: [], - ...config - }; -} - -function isOccupied(display: Display, x: number, y: number): boolean { - return display.occupiedCoordinates.some( - (coordinates) => coordinates.x === x && coordinates.y === y - ); -} - -/** - * Find the first slot in row `y` at or after `fromX` that no spanning cell has - * already claimed. - * - * @remarks - * The search must advance one slot at a time: counting blockers in a single - * pass can land the cell on another blocked slot, so two cells end up sharing - * one coordinate. - */ -function findFreeSlotX(display: Display, fromX: number, y: number): number { - let x = fromX; - while (isOccupied(display, x, y)) { - x += 1; - } - return x; -} - -/** - * Lay every `th` and `td` of `tnode` out on the matrix of `display`. - * - * @param computer - Measures each cell as it is laid down. Omit it to build - * the grid alone — coordinates and spans do not depend on the width the table - * resolves to, whereas constraints do, and measuring text is the costly half - * of a layout pass. Measure the cells once that width is known, as - * {@link TableLayout} does: the collapsing border model has to resolve the - * table's own borders — from cell coordinates alone — before the width those - * cells are measured against exists. - */ -export default function fillTableDisplay( - tnode: TNode, - display: Display, - computer?: TCellConstraintsComputer -) { - if (tnode.tagName === 'tr') { - display.maxY = display.maxY + 1; - display.offsetX = 0; - } - if (tnode.tagName === 'th' || tnode.tagName === 'td') { - const lenX = parseSpan(tnode.attributes.colspan, MAX_COLSPAN); - const lenY = parseSpan(tnode.attributes.rowspan, MAX_ROWSPAN); - const startY = display.maxY; - // `offsetX` is the slot cursor for the current row: cells are laid down - // left to right from wherever the previous one ended, skipping any slot a - // spanning cell from an earlier row has already claimed. Deriving the - // column from `nodeIndex` instead would let a stray non-cell element - // inside the row shift every following cell. - const startX = findFreeSlotX(display, display.offsetX, startY); - const constraints = computer - ? computer.computeCellConstraints(tnode) - : UNMEASURED_CONSTRAINTS; - const cell: DisplayCell = { - lenX, - lenY, - x: startX, - y: startY, - tnode, - constraints - }; - display.cells.push(cell); - display.offsetX = startX + lenX; - if (lenY > 1) { - // A spanning cell claims the whole rectangle it covers, so a cell that - // is both `colspan` and `rowspan` blocks every column it straddles in - // each of the rows below — not just its first one. - for (let y = startY + 1; y < lenY + startY; y++) { - for (let x = startX; x < startX + lenX; x++) { - display.occupiedCoordinates.push({ x, y }); - } - } - } - display.maxX = Math.max(display.maxX, startX + lenX - 1); - } else { - tnode.children.forEach((child) => - fillTableDisplay(child, display, computer) - ); - } -} diff --git a/packages/heuristic-table-plugin/src/helpers/measure.ts b/packages/heuristic-table-plugin/src/helpers/measure.ts index 2e30812..05cbe11 100644 --- a/packages/heuristic-table-plugin/src/helpers/measure.ts +++ b/packages/heuristic-table-plugin/src/helpers/measure.ts @@ -1,4 +1,5 @@ -import { I18nManager, ViewStyle } from 'react-native'; +import { ViewStyle } from 'react-native'; +import { isRTL } from './tableStyles'; type NativeBlockRetStyle = ViewStyle; type SpacingFields = Extract< @@ -38,9 +39,7 @@ export function getHorizontalMargins(style: NativeBlockRetStyle): number { * width it may pass on. */ export function getHorizontalInsets(style: NativeBlockRetStyle): number { - const rtl = - style.direction === 'rtl' || - (style.direction !== 'ltr' && I18nManager.isRTL); + const rtl = isRTL(style); const start = style.paddingInlineStart ?? style.paddingStart; const end = style.paddingInlineEnd ?? style.paddingEnd; const horizontal = diff --git a/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts b/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts index 38835c5..bcedbc3 100644 --- a/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts +++ b/packages/heuristic-table-plugin/src/helpers/reduceColumnConstraints.ts @@ -1,50 +1,97 @@ +import { clampWidth } from './resolveWidth'; import { CellProperties, TCellConstraints, TColumnConstraints } from '../shared-types'; +const EMPTY_COLUMN: TColumnConstraints = { + minWidth: 0, + spread: 0, + contentDensity: 0, + horizontalSpace: 0, + percentWidth: null +}; + +function largest(a: number | null, b: number | null): number | null { + if (a === null) return b; + if (b === null) return a; + return Math.max(a, b); +} + function getColumnMetrics(cells: CellProperties[]): TColumnConstraints { const column = cells .map((c) => c.constraints) .reduce( - (columnConstraints: TColumnConstraints, cellConstraints: TCellConstraints) => ({ + ( + columnConstraints: TColumnConstraints, + cellConstraints: TCellConstraints + ) => ({ minWidth: Math.max( columnConstraints.minWidth, cellConstraints.minWidth ), contentDensity: columnConstraints.contentDensity + cellConstraints.contentDensity, - spread: Math.max(columnConstraints.spread, cellConstraints.maxWidth) + spread: Math.max(columnConstraints.spread, cellConstraints.maxWidth), + horizontalSpace: Math.max( + columnConstraints.horizontalSpace, + cellConstraints.horizontalSpace ?? 0 + ), + percentWidth: largest( + columnConstraints.percentWidth, + cellConstraints.percentWidth ?? null + ) }), - { minWidth: 0, spread: 0, contentDensity: 0 } + EMPTY_COLUMN ); // CSS 2.1 §17.5.2.2 derives the column minimum and maximum from the same // cells, each floored by the column 'width' — so a maximum below its own // minimum is not a state the spec can produce. Restate it here so callers // may clamp against `spread` without starving the column. - return { ...column, spread: Math.max(column.spread, column.minWidth) }; + return { + ...column, + spread: clampWidth(column.spread, column.minWidth, null) + }; } -function splitColspanCells(cell: CellProperties): CellProperties | CellProperties[] { - if (cell.lenX > 1) { - const cells: CellProperties[] = []; - for (let i = 0; i < cell.lenX; i++) { - cells[i] = { - lenX: 1, - lenY: cell.lenY, - constraints: { - minWidth: cell.constraints.minWidth / cell.lenX, - maxWidth: cell.constraints.maxWidth / cell.lenX, - contentDensity: cell.constraints.contentDensity / cell.lenX - }, - x: cell.x + i, - y: cell.y - }; - } - return cells; +/** + * Share a spanning cell's figures across the columns it covers. + * + * @remarks + * Every per-cell quantity a column is reduced from is divided the same way, so + * that a `colspan` cannot contribute its whole width, spacing or percentage to + * each of its columns in turn. Keeping them together is the point: they were + * once spread in three places, and two of them had already drifted apart on + * whether a span overrunning the grid is clamped to it. + */ +function splitColspanCells( + cell: CellProperties +): CellProperties | CellProperties[] { + if (cell.lenX === 1) { + return cell; + } + const share = (value: T) => + value == null ? null : (value as number) / cell.lenX; + const cells: CellProperties[] = []; + for (let i = 0; i < cell.lenX; i++) { + cells[i] = { + lenX: 1, + lenY: cell.lenY, + constraints: { + minWidth: cell.constraints.minWidth / cell.lenX, + maxWidth: cell.constraints.maxWidth / cell.lenX, + contentDensity: cell.constraints.contentDensity / cell.lenX, + horizontalSpace: (cell.constraints.horizontalSpace ?? 0) / cell.lenX, + ...(share(cell.constraints.percentWidth) === null + ? null + : { percentWidth: share(cell.constraints.percentWidth)! }) + }, + x: cell.x + i, + y: cell.y + }; } - return cell; + return cells; } export default function reduceColumnConstraints( diff --git a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts index be00d40..994e81b 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts @@ -1,6 +1,6 @@ import { TNode } from '@native-html/render'; import { ViewStyle } from 'react-native'; -import { Display } from '../shared-types'; +import { TableGrid } from '../shared-types'; import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle, @@ -18,15 +18,18 @@ export interface ResolvedCellStyle { } export default function resolveTableStyles( - display: Display, + grid: TableGrid, tableStyle: ViewStyle, collapse: boolean, configStyles: ReadonlyMap, - neighbours = collapse ? indexCellNeighbours(display.cells) : undefined + // Required: `TableLayout` indexes once per layout and passes it to both + // measurement passes, so a default here would be a second place stating the + // "index only when collapsing" rule, and would never run. + neighbours: ReturnType | undefined ) { const styles = new Map(); const configuredStyles = new Map(); - for (const { tnode } of display.cells) { + for (const { tnode } of grid.cells) { const source = getSourceBlockStyle(tnode); const configured = resolveConfiguredCellStyle(configStyles.get(tnode)); configuredStyles.set(tnode, configured); @@ -34,13 +37,14 @@ export default function resolveTableStyles( } const getCellStyle = ({ tnode }: { tnode: TNode }) => styles.get(tnode)!; const tableBorderStyle = collapse - ? getCollapsedTableBorderStyle(display, tableStyle, getCellStyle) + ? getCollapsedTableBorderStyle(grid, tableStyle, getCellStyle) : null; const cellStyles = new Map(); - for (const cell of display.cells) { + for (const cell of grid.cells) { const borderStyle = collapse ? getCollapsedCellBorderStyle(cell, getCellStyle(cell), { - ...display, + maxX: grid.maxX, + maxY: grid.maxY, tableBorderStyle, getCellStyle, neighbours: neighbours?.get(cell) diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts index 0ddbb29..facdf0a 100644 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts @@ -1,6 +1,6 @@ import { I18nManager, ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; -import { Display, DisplayCell, TableCell } from '../shared-types'; +import { DisplayCell, TableCell, TableGrid } from '../shared-types'; import type { CellNeighbours } from './indexCellNeighbours'; export type BorderCollapse = 'collapse' | 'separate'; @@ -83,7 +83,7 @@ export const DEFAULT_CELL_PADDING = 1; /** The four physical edges of a box, spelled as React Native style suffixes. */ export type BoxSide = 'Bottom' | 'Left' | 'Right' | 'Top'; -export const BOX_SIDES: readonly BoxSide[] = ['Top', 'Right', 'Bottom', 'Left']; +export const BOX_SIDES = ['Top', 'Right', 'Bottom', 'Left'] as const; /** * Whether a style resolves its logical edges right-to-left. @@ -224,7 +224,7 @@ export function getDefaultCellPaddingStyle( ...declaredStyles: (ViewStyle | null | undefined)[] ): ViewStyle { const resolvedStyle: ViewStyle = {}; - for (const side of ['Top', 'Right', 'Bottom', 'Left'] as const) { + for (const side of BOX_SIDES) { const isDeclared = declaredStyles.some((style) => style ? paddingSideKeys[side].some((property) => style[property] != null) @@ -433,7 +433,7 @@ type CollapsibleCell = Pick; */ type CollapsibleMatrix = { cells: readonly C[]; -} & Pick; +} & Pick; /** * Whether a cell sits against one of the table's own edges. @@ -450,7 +450,7 @@ type CollapsibleMatrix = { function isAtOuterEdge( cell: Pick, side: BorderSide, - { maxX, maxY }: Pick + { maxX, maxY }: Pick ): boolean { switch (side) { case 'Top': @@ -497,7 +497,7 @@ export function getCollapsedTableBorderStyle( ): ViewStyle { const resolvedStyle: ViewStyle = clearLogicalBorders(tableStyle); let strongestStyle: BorderCandidate['style'] | null = null; - for (const side of ['Top', 'Right', 'Bottom', 'Left'] as const) { + for (const side of BOX_SIDES) { const winner = cellsAtOuterEdge(matrix, side).reduce( (currentWinner, cell) => resolveBorderConflict( diff --git a/packages/heuristic-table-plugin/src/index.ts b/packages/heuristic-table-plugin/src/index.ts index 02c1ee6..a42385b 100644 --- a/packages/heuristic-table-plugin/src/index.ts +++ b/packages/heuristic-table-plugin/src/index.ts @@ -33,8 +33,8 @@ export { TableRenderer, ThRenderer, TdRenderer, colgroupModel }; */ const renderers: Record<'th' | 'td' | 'table', CustomBlockRenderer> = { table: TableRenderer, - th: ThRenderer as any, - td: TdRenderer as any + th: ThRenderer, + td: TdRenderer }; export { default as useHtmlTableProps } from './useHtmlTableProps'; diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 50bb28e..0a334d9 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -50,6 +50,22 @@ export interface TColumnConstraints extends TConstraintsBase { * densities, whereas spread is a maximum. */ spread: number; + /** + * The horizontal padding and border the column carries, already included in + * {@link TConstraintsBase.minWidth} and {@link TColumnConstraints.spread}. + * + * @remarks + * The widest of its cells' insets, a `colspan` contributing its share to + * each column it covers — the same reduction `minWidth` gets, which is the + * figure this is held out of when surplus width is shared over content. + */ + horizontalSpace: number; + /** + * The fraction of the table width the column's cells prefer, or `null` when + * none declares one. A `colspan` contributes its share to each column it + * covers; where cells disagree the largest wins, as it does for `minWidth`. + */ + percentWidth: number | null; } /** @@ -192,12 +208,23 @@ export interface Settings contentWidth: number; } -export interface Display extends Settings { - maxY: number; - maxX: number; - occupiedCoordinates: Array; - offsetX: number; +/** + * Where every cell of a table sits, and how far the matrix extends. + * + * @remarks + * The durable result of laying a table out, and the only part of it anything + * downstream reads. `maxX`/`maxY` are the last occupied column and row, so a + * span overrunning them is clipped rather than growing the table. + * + * Deliberately holds neither configuration nor build-time scratch: it used to + * extend {@link Settings} and carry the grid-filling cursor, which meant + * `contentWidth` changed meaning halfway through a layout and every consumer + * had to narrow the type back down to the three fields it wanted. + */ +export interface TableGrid { cells: DisplayCell[]; + maxX: number; + maxY: number; } /** diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index a81984f..126ef08 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -12,7 +12,8 @@ import { getCollapsedCellBorderStyle, resolveConfiguredCellStyle, resolveCellVerticalAlign, - getSourceBlockStyle + getSourceBlockStyle, + DEFAULT_CELL_VERTICAL_ALIGN } from './helpers/tableStyles'; /** @@ -89,7 +90,8 @@ export default function useHtmlTableCellProps({ const alignmentStyles = { justifyContent: verticalAlign ? justifyContentForVerticalAlign[verticalAlign] - : (props.style?.justifyContent ?? 'center'), + : (props.style?.justifyContent ?? + justifyContentForVerticalAlign[DEFAULT_CELL_VERTICAL_ALIGN]), ...(cell.lenX > 1 ? { alignItems: 'center' as const } : null) }; // The collapsing model has to weigh every border the cell actually paints, diff --git a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts index d4f705d..86a1820 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts @@ -56,6 +56,12 @@ export default function useHtmlTableProps( } = {} ): HTMLTableProps { const table = useRendererProps('table'); + // Destructured field by field, and memoized on the fields rather than on + // `table`, deliberately. `RenderersPropsProvider` memoizes on the whole + // `renderersProps` prop, so an inline `renderersProps={{ table: {...} }}` — + // the form the README shows — yields a new `table` object on every render. + // Depending on `table` itself would therefore rebuild `settings`, and with + // it the entire `TableLayout`, on every render of every table. const forceStretch = table?.forceStretch; const baseFontCoeff = table?.baseFontCoeff; const fontWeightCoeffs = table?.fontWeightCoeffs; diff --git a/yarn.lock b/yarn.lock index 019c78d..971beb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3527,29 +3527,25 @@ __metadata: version: 0.0.0-use.local resolution: "@native-html/heuristic-table-plugin@workspace:packages/heuristic-table-plugin" dependencies: - "@babel/cli": "npm:^7.28.6" "@babel/core": "npm:^7.29.0" + "@babel/plugin-transform-private-methods": "npm:^7.28.6" + "@babel/plugin-transform-private-property-in-object": "npm:^7.28.6" "@babel/plugin-transform-react-jsx": "npm:^7.28.6" "@babel/preset-typescript": "npm:^7.28.5" "@babel/runtime": "npm:^7.28.6" "@microsoft/api-documenter": "npm:^7.29.6" "@microsoft/api-extractor": "npm:7.57.6" "@native-html/render": "npm:1.0.0-alpha.0" - "@testing-library/react": "npm:16.3.2" + "@native-html/transient-render-engine": "npm:12.0.0-alpha.0" "@testing-library/react-native": "npm:^13.3.3" "@tsconfig/react-native": "npm:^3.0.9" - "@types/html-validator": "npm:^5.0.6" "@types/jest": "npm:^30.0.0" - "@types/prop-types": "npm:^15.7.15" "@types/react": "npm:^19.2.14" - "@types/react-native": "npm:^0.73.0" babel-jest: "npm:^30.2.0" - babel-plugin-inline-import: "npm:^3.0.0" + babel-plugin-syntax-hermes-parser: "npm:^0.32.0" eslint: "npm:^10.0.2" jest: "npm:^30.2.0" metro-react-native-babel-preset: "npm:^0.77.0" - metro-react-native-babel-transformer: "npm:^0.77.0" - prop-types: "npm:^15.8.1" react: "npm:19.2.0" react-native: "npm:0.83.2" react-native-builder-bob: "npm:^0.40.18" From 779d3b488896292d550ff1fdb76763d132ec2188 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 21:53:58 +0200 Subject: [PATCH 19/21] refactor(heuristic-table-plugin): split oversized modules and name the layout phases --- .../docs/heuristic-table-plugin.htmltable.md | 61 +- .../heuristic-table-plugin.htmltableprops.md | 19 +- ...ic-table-plugin.htmltableprops.settings.md | 11 - .../docs/heuristic-table-plugin.md | 24 +- .../docs/heuristic-table-plugin.settings.md | 4 +- .../docs/heuristic-table-plugin.tdrenderer.md | 2 +- .../docs/heuristic-table-plugin.threnderer.md | 2 +- ...euristic-table-plugin.usehtmltableprops.md | 2 +- .../etc/heuristic-table-plugin.api.md | 4 +- .../heuristic-table-plugin/src/HTMLTable.tsx | 18 +- .../heuristic-table-plugin/src/TableLayout.ts | 193 ++---- .../src/TableRenderContext.ts | 17 +- .../heuristic-table-plugin/src/TdRenderer.ts | 10 + .../heuristic-table-plugin/src/TdRenderer.tsx | 18 - .../heuristic-table-plugin/src/ThRenderer.ts | 12 +- .../src/TreeRenderer.tsx | 24 +- .../__tests__/useHtmlTableCellProps.test.ts | 28 +- .../src/__tests__/writingDirection.test.ts | 2 +- .../src/createCellRenderer.ts | 27 + .../src/helpers/TCellConstraintsComputer.ts | 39 +- .../TCellConstraintsComputer.test.ts | 12 +- ...tableStyles.test.ts => cellStyles.test.ts} | 13 +- .../__tests__/computeColumnWidths.test.ts | 93 +++ .../__tests__/createRenderTree.test.ts | 2 +- .../src/helpers/__tests__/makeRows.test.ts | 31 + .../src/helpers/borderModel.ts | 36 + .../src/helpers/borderSpacingGeometry.ts | 32 + .../src/helpers/boxSides.ts | 27 + .../src/helpers/cellPadding.ts | 225 +++++++ .../src/helpers/cellVerticalAlign.ts | 93 +++ .../src/helpers/collapseBorders.ts | 302 +++++++++ .../src/helpers/composeCellStyle.ts | 25 - .../src/helpers/computeColumnWidths.ts | 281 +++++--- .../src/helpers/createRenderTree.ts | 18 +- .../src/helpers/inlineStyle.ts | 27 + .../src/helpers/measure.ts | 17 +- .../src/helpers/measureTable.ts | 129 ++++ .../src/helpers/relaxHeightConstraint.ts | 5 + .../src/helpers/resolveAvailableWidth.ts | 8 +- .../src/helpers/resolveBorderSpacing.ts | 2 +- .../src/helpers/resolveTableStyles.ts | 10 +- .../src/helpers/resolveTableWidths.ts | 90 +++ .../src/helpers/tableStyles.ts | 631 ------------------ .../src/shared-types.ts | 25 +- .../src/useHtmlTableCellProps.ts | 15 +- .../src/useHtmlTableProps.ts | 18 +- packages/plugins-core/package.json | 2 +- ...inkPressTargetToOnDOMLinkPressArgs.test.ts | 4 +- .../plugins-core/src/__tests__/tsconfig.json | 3 +- 49 files changed, 1593 insertions(+), 1100 deletions(-) delete mode 100644 packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.settings.md create mode 100644 packages/heuristic-table-plugin/src/TdRenderer.ts delete mode 100644 packages/heuristic-table-plugin/src/TdRenderer.tsx create mode 100644 packages/heuristic-table-plugin/src/createCellRenderer.ts rename packages/heuristic-table-plugin/src/helpers/__tests__/{tableStyles.test.ts => cellStyles.test.ts} (98%) create mode 100644 packages/heuristic-table-plugin/src/helpers/borderModel.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/borderSpacingGeometry.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/boxSides.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/cellPadding.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/cellVerticalAlign.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/collapseBorders.ts delete mode 100644 packages/heuristic-table-plugin/src/helpers/composeCellStyle.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/inlineStyle.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/measureTable.ts create mode 100644 packages/heuristic-table-plugin/src/helpers/resolveTableWidths.ts delete mode 100644 packages/heuristic-table-plugin/src/helpers/tableStyles.ts diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltable.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltable.md index d5bb208..7248557 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltable.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltable.md @@ -2,12 +2,69 @@ [Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [HTMLTable](./heuristic-table-plugin.htmltable.md) -## HTMLTable variable +## HTMLTable() function A component to render tables. **Signature:** ```typescript -HTMLTable: React.NamedExoticComponent +declare function HTMLTable(input: HTMLTableProps): React.JSX.Element; ``` + +## Parameters + + + + +
+ +Parameter + + + + +Type + + + + +Description + + +
+ +{ layout, TDefaultRenderer, config, ...props } + + + + +(not declared) + + + + + +
+ +input + + + + +[HTMLTableProps](./heuristic-table-plugin.htmltableprops.md) + + + + + +
+ +**Returns:** + +React.JSX.Element + +## Remarks + +Deliberately not wrapped in `memo`. The render engine rebuilds `style`, `propsForChildren` and the container props on every render, so a shallow prop comparison can never hold and the wrapper only ever costs a compare. The expensive half — building the table layout — is memoized inside [useHtmlTableProps()](./heuristic-table-plugin.usehtmltableprops.md) instead, where the inputs are stable. + diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.md index 97c939e..9b4c10b 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.md @@ -4,7 +4,7 @@ ## HTMLTableProps interface -Props for the [HTMLTable](./heuristic-table-plugin.htmltable.md) component. +Props for the [HTMLTable()](./heuristic-table-plugin.htmltable.md) component. **Signature:** @@ -69,23 +69,6 @@ TableLayout - - - -[settings](./heuristic-table-plugin.htmltableprops.settings.md) - - - - - - - -[Settings](./heuristic-table-plugin.settings.md) - - - - - diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.settings.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.settings.md deleted file mode 100644 index ff9e20d..0000000 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.htmltableprops.settings.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) > [HTMLTableProps](./heuristic-table-plugin.htmltableprops.md) > [settings](./heuristic-table-plugin.htmltableprops.settings.md) - -## HTMLTableProps.settings property - -**Signature:** - -```typescript -settings: Settings; -``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md index d94d56d..7cc1e90 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.md @@ -19,6 +19,17 @@ Description +[HTMLTable({ layout, TDefaultRenderer, config, ...props }, input)](./heuristic-table-plugin.htmltable.md) + + + + +A component to render tables. + + + + + [useHtmlTableCellProps({ propsFromParent, ...props }, input)](./heuristic-table-plugin.usehtmltablecellprops.md) @@ -102,7 +113,7 @@ Options to customize this plugin renderers. -Props for the [HTMLTable](./heuristic-table-plugin.htmltable.md) component. +Props for the [HTMLTable()](./heuristic-table-plugin.htmltable.md) component. @@ -209,17 +220,6 @@ Element model required for colgroup children to be available to the table layout The coefficients used when the config supplies none. - - - -[HTMLTable](./heuristic-table-plugin.htmltable.md) - - - - -A component to render tables. - - diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md index 3f1e785..0d46d25 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.settings.md @@ -15,9 +15,9 @@ export interface Settings extends Omit; it is not the shape a consumer writes. Author configuration goes to `renderersProps.table` as a [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md), which carries no [Settings.contentWidth](./heuristic-table-plugin.settings.contentwidth.md). +This is resolved by [useHtmlTableProps()](./heuristic-table-plugin.usehtmltableprops.md) and handed to [HTMLTable()](./heuristic-table-plugin.htmltable.md); it is not the shape a consumer writes. Author configuration goes to `renderersProps.table` as a [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md), which carries no [Settings.contentWidth](./heuristic-table-plugin.settings.contentwidth.md). -[HeuristicTablePluginConfig.growBeyondHeight](./heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md) is deliberately absent: it decides whether a declared table `height` becomes a viewport or a minimum, which is a rendering choice [HTMLTable](./heuristic-table-plugin.htmltable.md) reads from the config directly. Excluding it here keeps `useHtmlTableProps` from having to copy a field no layout pass reads — and makes that a compile error rather than a silent omission if it ever does. +[HeuristicTablePluginConfig.growBeyondHeight](./heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md) is deliberately absent: it decides whether a declared table `height` becomes a viewport or a minimum, which is a rendering choice [HTMLTable()](./heuristic-table-plugin.htmltable.md) reads from the config directly. Excluding it here keeps `useHtmlTableProps` from having to copy a field no layout pass reads — and makes that a compile error rather than a silent omission if it ever does. ## Properties diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.tdrenderer.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.tdrenderer.md index 7673398..bedb974 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.tdrenderer.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.tdrenderer.md @@ -9,5 +9,5 @@ The renderer component for `td` tag. **Signature:** ```typescript -TdRenderer: CustomBlockRenderer +TdRenderer: import("@native-html/render").CustomBlockRenderer ``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.threnderer.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.threnderer.md index cb90e67..10b37b3 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.threnderer.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.threnderer.md @@ -9,5 +9,5 @@ The renderer component for `th` tag. **Signature:** ```typescript -ThRenderer: CustomBlockRenderer +ThRenderer: import("@native-html/render").CustomBlockRenderer ``` diff --git a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.usehtmltableprops.md b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.usehtmltableprops.md index 9a557ad..acdd6f4 100644 --- a/packages/heuristic-table-plugin/docs/heuristic-table-plugin.usehtmltableprops.md +++ b/packages/heuristic-table-plugin/docs/heuristic-table-plugin.usehtmltableprops.md @@ -82,5 +82,5 @@ _(Optional)_ Customize this hook behavior. [HTMLTableProps](./heuristic-table-plugin.htmltableprops.md) -props for the [HTMLTable](./heuristic-table-plugin.htmltable.md) component. +props for the [HTMLTable()](./heuristic-table-plugin.htmltable.md) component. diff --git a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md index 46f8a50..b4697f5 100644 --- a/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md +++ b/packages/heuristic-table-plugin/etc/heuristic-table-plugin.api.md @@ -60,7 +60,7 @@ export interface HeuristicTablePluginConfig { } // @public -export const HTMLTable: React_2.NamedExoticComponent; +export function HTMLTable(input: HTMLTableProps): React_2.JSX.Element; // @public export interface HTMLTableProps extends CustomRendererProps { @@ -70,8 +70,6 @@ export interface HTMLTableProps extends CustomRendererProps { // // (undocumented) layout: TableLayout; - // (undocumented) - settings: Settings; } // @public diff --git a/packages/heuristic-table-plugin/src/HTMLTable.tsx b/packages/heuristic-table-plugin/src/HTMLTable.tsx index 145f9f6..525cd7c 100644 --- a/packages/heuristic-table-plugin/src/HTMLTable.tsx +++ b/packages/heuristic-table-plugin/src/HTMLTable.tsx @@ -1,4 +1,4 @@ -import React, { memo, PropsWithChildren, useMemo } from 'react'; +import React, { PropsWithChildren, useMemo } from 'react'; import { ScrollView, View } from 'react-native'; import TreeRenderer from './TreeRenderer'; import TableRenderContext, { @@ -64,20 +64,26 @@ function Container({ * * @param props - Props from {@link useHtmlTableProps} hook. * + * @remarks + * Deliberately not wrapped in `memo`. The render engine rebuilds `style`, + * `propsForChildren` and the container props on every render, so a shallow + * prop comparison can never hold and the wrapper only ever costs a compare. + * The expensive half — building the table layout — is memoized inside + * {@link useHtmlTableProps} instead, where the inputs are stable. + * * @public */ -const HTMLTable = memo(function HTMLTable({ +function HTMLTable({ layout, TDefaultRenderer, - settings, config, ...props }: HTMLTableProps) { const tableWidth = layout.totalWidth; // `layout` measures against the width the table's ancestors actually leave // it, which is what `contentWidth` would be if it were narrowed on the way - // down the tree. Sizing the container off `settings.contentWidth` instead - // would spill the table out of every padded ancestor it sits in. + // down the tree. Sizing the container off the document `contentWidth` + // instead would spill the table out of every padded ancestor it sits in. const insets = layout.horizontalInsets; const tableBorderStyle = layout.tableBorderStyle; const renderContext = useMemo( @@ -136,6 +142,6 @@ const HTMLTable = memo(function HTMLTable({
); -}); +} export default HTMLTable; diff --git a/packages/heuristic-table-plugin/src/TableLayout.ts b/packages/heuristic-table-plugin/src/TableLayout.ts index 9786213..8f52400 100644 --- a/packages/heuristic-table-plugin/src/TableLayout.ts +++ b/packages/heuristic-table-plugin/src/TableLayout.ts @@ -1,34 +1,45 @@ import sum from './helpers/sum'; +import { totalHorizontalSpacing } from './helpers/borderSpacingGeometry'; import resolveBorderSpacing, { BorderSpacing } from './helpers/resolveBorderSpacing'; import type { CellContentBox } from './CellContentWidthContext'; import { ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; -import computeColumnWidths from './helpers/computeColumnWidths'; import createRenderTree, { makeTableCells } from './helpers/createRenderTree'; import buildTableGrid from './helpers/buildTableGrid'; import TCellConstraintsComputer from './helpers/TCellConstraintsComputer'; import indexCellNeighbours from './helpers/indexCellNeighbours'; import { Settings, TableCell, TableGrid, TableRoot } from './shared-types'; import extractColumnWidths from './helpers/extractColumnWidths'; -import { clampWidth, resolveWidthConstraints } from './helpers/resolveWidth'; -import resolveAvailableWidth from './helpers/resolveAvailableWidth'; -import { getHorizontalInsets, getHorizontalMargins } from './helpers/measure'; -import { - getSourceBlockStyle, - resolveBorderCollapse -} from './helpers/tableStyles'; -import resolveTableStyles, { - ResolvedCellStyle -} from './helpers/resolveTableStyles'; +import { resolveBorderCollapse } from './helpers/borderModel'; +import { getSourceBlockStyle } from './helpers/cellPadding'; +import type { ResolvedCellStyle } from './helpers/resolveTableStyles'; +import measureTable from './helpers/measureTable'; +import resolveTableWidths from './helpers/resolveTableWidths'; + +/** No cell style callback has run yet. */ +const NO_CONFIG_STYLES: ReadonlyMap = new Map(); /** - * Tables fill the width their containing block leaves them unless the config - * opts out, so that a table reads as part of the surrounding document rather - * than as a shrink-wrapped island. + * Ask the config for a style per cell, against provisional widths. + * + * @remarks + * Results are copied rather than stored by reference, so that a callback + * handing back a shared mutable object cannot have it changed underneath the + * second measurement pass. */ -const DEFAULT_FORCE_STRETCH = true; +function collectConfigStyles( + cells: readonly TableCell[], + getStyleForCell: NonNullable +): ReadonlyMap { + const configStyles = new Map(); + for (const cell of cells) { + const configured = getStyleForCell(cell); + configStyles.set(cell.tnode, configured ? { ...configured } : null); + } + return configStyles; +} export default class TableLayout { public readonly display: TableGrid; @@ -75,136 +86,64 @@ export default class TableLayout { const style = getSourceBlockStyle(tnode); this.borderCollapse = resolveBorderCollapse(tnode, config.borderCollapse); this.borderSpacing = resolveBorderSpacing(tnode, this.borderCollapse); - const containingWidth = resolveAvailableWidth( - tnode, - config.contentWidth, - cellContentBox - ); - const availableWidth = Math.max( - 0, - containingWidth - getHorizontalMargins(style) - ); - // Percentages resolve against the width the table may actually occupy, - // margins already deducted, rather than against the whole containing - // block. Resolving `width:100%` against the latter would hand the columns - // more width than the table box is allowed — by exactly the margins — and - // the surplus would then be shown through a horizontal scroller the same - // table without a declared width never gets. An absolute width is - // untouched by this and still overflows into that scroller when it does - // not fit, as it should. - const { width, minWidth, maxWidth } = resolveWidthConstraints( - tnode, - availableWidth - ); - const declaredTableWidth = - width === null ? null : clampWidth(width, minWidth, maxWidth); - // `min-width` and `max-width` bound the table width whether it is declared - // or filled. A table that merely asks for *at least* 200px still fills the - // width it was offered; one capped at 300px stops there rather than - // stretching past its own ceiling. - const usedTableWidth = clampWidth( - declaredTableWidth ?? availableWidth, - minWidth, - maxWidth - ); - const forceStretch = - (config.forceStretch ?? DEFAULT_FORCE_STRETCH) || - declaredTableWidth !== null; - // Build the grid once; styles may require a second measurement pass. - const display = buildTableGrid(tnode); + const widths = resolveTableWidths(tnode, style, config, cellContentBox); + // Build the grid once: coordinates and spans do not depend on any width, + // and neighbours follow from coordinates alone. + const grid = buildTableGrid(tnode); const neighbours = this.borderCollapse - ? indexCellNeighbours(display.cells) + ? indexCellNeighbours(grid.cells) : undefined; - const spacingWidth = display.cells.length - ? (display.maxX + 2) * this.borderSpacing.horizontal + const spacingWidth = grid.cells.length + ? totalHorizontalSpacing(grid.maxX, this.borderSpacing.horizontal) : 0; - const declaredColumnWidths = extractColumnWidths(tnode); - const configStyles = new Map(); + // Built once and shared between passes: its cache of per-cell intrinsic + // constraints is what keeps the second pass from re-walking every text + // node, so constructing it per pass would silently undo that. const computer = new TCellConstraintsComputer({ baseFontCoeff: config.baseFontCoeff, fontWeightCoeffs: config.fontWeightCoeffs }); - const measure = () => { - const resolved = resolveTableStyles( - display, - style, - this.borderCollapse, + const declaredColumnWidths = extractColumnWidths(tnode); + const pass = (configStyles: ReadonlyMap) => + measureTable({ + grid, + tableStyle: style, + borderCollapse: this.borderCollapse, + neighbours, configStyles, - neighbours - ); - const insets = getHorizontalInsets({ - ...style, - ...resolved.tableBorderStyle - }); - // The width left for the columns, once the table's own padding, border - // and border-spacing are taken out of the width it may occupy. - const assignableWidth = Math.max( - 0, - usedTableWidth - insets - spacingWidth - ); - for (const cell of display.cells) { - const constraints = computer.computeCellConstraints( - cell.tnode, - resolved.cellStyles.get(cell.tnode)!.style, - assignableWidth - ); - // A spanning cell also occupies the gaps between its columns. - const internalSpacing = (cell.lenX - 1) * this.borderSpacing.horizontal; - cell.constraints = { - ...constraints, - minWidth: Math.max(0, constraints.minWidth - internalSpacing), - maxWidth: Math.max(0, constraints.maxWidth - internalSpacing) - }; - } - let columnWidths = computeColumnWidths( - // A table with a specified width distributes that width over its - // columns; shrink-to-fit only applies when the table width is auto, - // and is opt-in. - { cells: display.cells, assignableWidth, forceStretch }, + computer, + widths, + borderSpacing: this.borderSpacing, + spacingWidth, declaredColumnWidths - ); - const minLayoutWidth = Math.max( - 0, - (minWidth ?? 0) - insets - spacingWidth - ); - if (sum(columnWidths) < minLayoutWidth) { - const raised = computeColumnWidths( - { - cells: display.cells, - assignableWidth: minLayoutWidth, - forceStretch: true - }, - declaredColumnWidths - ); - if (sum(raised) > sum(columnWidths)) columnWidths = raised; - } - return { ...resolved, insets, columnWidths }; - }; - let measured = measure(); + }); + let measured = pass(NO_CONFIG_STYLES); if (config.getStyleForCell) { - // Freeze callback results against provisional widths. Re-evaluating after - // each resize could oscillate for a callback that branches on width. - for (const cell of makeTableCells( - display, - measured.columnWidths, - this.borderSpacing.horizontal - )) { - const configured = config.getStyleForCell.call(null, cell); - configStyles.set(cell.tnode, configured ? { ...configured } : null); - } - measured = measure(); + // The callback needs cells, which need widths, which need the styles the + // callback returns. The cycle is broken by freezing its results against + // the widths of a first pass: re-evaluating after each resize could + // oscillate for a callback that branches on width. + measured = pass( + collectConfigStyles( + makeTableCells(grid, measured.columnWidths, this.borderSpacing.horizontal), + config.getStyleForCell + ) + ); } this.tableBorderStyle = measured.tableBorderStyle; this.cellStyles = measured.cellStyles; this.horizontalInsets = measured.insets; - this.availableWidth = availableWidth; - this.usedWidth = Math.max(0, Math.min(usedTableWidth, availableWidth)); + this.availableWidth = widths.availableWidth; + this.usedWidth = Math.max( + 0, + Math.min(widths.usedTableWidth, widths.availableWidth) + ); this.viewportWidth = Math.max(0, this.usedWidth - measured.insets); - this.display = display; + this.display = grid; this.columnWidths = measured.columnWidths; this.totalWidth = sum(this.columnWidths) + spacingWidth; this.cells = makeTableCells( - display, + grid, this.columnWidths, this.borderSpacing.horizontal ); diff --git a/packages/heuristic-table-plugin/src/TableRenderContext.ts b/packages/heuristic-table-plugin/src/TableRenderContext.ts index d4843b2..0f09ef9 100644 --- a/packages/heuristic-table-plugin/src/TableRenderContext.ts +++ b/packages/heuristic-table-plugin/src/TableRenderContext.ts @@ -1,9 +1,8 @@ import { createContext } from 'react'; -import { ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; import { BorderSpacing } from './helpers/resolveBorderSpacing'; import { ResolvedCellStyle } from './helpers/resolveTableStyles'; -import { HeuristicTablePluginConfig } from './shared-types'; +import { TableGeometry } from './shared-types'; /** * Everything the render tree needs which is the same for every node in one @@ -15,21 +14,9 @@ import { HeuristicTablePluginConfig } from './shared-types'; * level in between would otherwise have to accept and forward values it makes * no use of. */ -export interface TableRenderContextValue { +export interface TableRenderContextValue extends TableGeometry { borderSpacing: BorderSpacing; cellStyles: ReadonlyMap; - borderCollapse: boolean; - /** - * The wrapper edge the collapsing model resolved. - * - * @remarks - * Cells need this, not just their position in the matrix: an outer boundary - * the wrapper leaves bare is still theirs to paint. - */ - tableBorderStyle: ViewStyle | null; - maxX: number; - maxY: number; - config?: HeuristicTablePluginConfig; } const DEFAULT_CONTEXT: TableRenderContextValue = { diff --git a/packages/heuristic-table-plugin/src/TdRenderer.ts b/packages/heuristic-table-plugin/src/TdRenderer.ts new file mode 100644 index 0000000..093b4bd --- /dev/null +++ b/packages/heuristic-table-plugin/src/TdRenderer.ts @@ -0,0 +1,10 @@ +import createCellRenderer from './createCellRenderer'; + +/** + * The renderer component for `td` tag. + * + * @public + */ +const TdRenderer = createCellRenderer('td'); + +export default TdRenderer; diff --git a/packages/heuristic-table-plugin/src/TdRenderer.tsx b/packages/heuristic-table-plugin/src/TdRenderer.tsx deleted file mode 100644 index 9861e2c..0000000 --- a/packages/heuristic-table-plugin/src/TdRenderer.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { CustomBlockRenderer } from '@native-html/render'; -import useHtmlTableCellProps from './useHtmlTableCellProps'; - -/** - * The renderer component for `td` tag. - * - * @param props - Component props. - * @public - */ -const TdRenderer: CustomBlockRenderer = function TdRenderer(props) { - return React.createElement( - props.TDefaultRenderer, - useHtmlTableCellProps(props) - ); -}; - -export default TdRenderer; diff --git a/packages/heuristic-table-plugin/src/ThRenderer.ts b/packages/heuristic-table-plugin/src/ThRenderer.ts index e212aac..4e50b04 100644 --- a/packages/heuristic-table-plugin/src/ThRenderer.ts +++ b/packages/heuristic-table-plugin/src/ThRenderer.ts @@ -1,18 +1,10 @@ -import React from 'react'; -import { CustomBlockRenderer } from '@native-html/render'; -import useHtmlTableCellProps from './useHtmlTableCellProps'; +import createCellRenderer from './createCellRenderer'; /** * The renderer component for `th` tag. * - * @param props - Component props. * @public */ -const ThRenderer: CustomBlockRenderer = function ThRenderer(props) { - return React.createElement( - props.TDefaultRenderer, - useHtmlTableCellProps(props) - ); -}; +const ThRenderer = createCellRenderer('th'); export default ThRenderer; diff --git a/packages/heuristic-table-plugin/src/TreeRenderer.tsx b/packages/heuristic-table-plugin/src/TreeRenderer.tsx index 43e53a8..1635369 100644 --- a/packages/heuristic-table-plugin/src/TreeRenderer.tsx +++ b/packages/heuristic-table-plugin/src/TreeRenderer.tsx @@ -7,8 +7,14 @@ import { } from './shared-types'; import CellContentWidthContext from './CellContentWidthContext'; import TableRenderContext from './TableRenderContext'; +import type { ResolvedCellStyle } from './helpers/resolveTableStyles'; import { getHorizontalInsets } from './helpers/measure'; +/** The horizontal padding and border a resolved cell style carries, if any. */ +function getCellInsets(resolved: ResolvedCellStyle | undefined): number { + return resolved ? getHorizontalInsets(resolved.style) : 0; +} + const styles = StyleSheet.create({ colContainer: { flexDirection: 'column', flexGrow: 1 }, rowContainer: { flexDirection: 'row', flexGrow: 1 } @@ -62,10 +68,12 @@ export default function TreeRenderer({ node.type === 'cell' ? { tnode: node.tnode, + // A cell rendered without a resolved style has no insets to + // subtract — the same absent-layout case `resolvedCellStyle` + // below is typed for, rather than one to assert away here. contentWidth: Math.max( 0, - node.width - - getHorizontalInsets(cellStyles.get(node.tnode)!.style) + node.width - getCellInsets(cellStyles.get(node.tnode)) ) } : undefined, @@ -113,7 +121,7 @@ export default function TreeRenderer({ } ]} > - + ); } @@ -121,7 +129,7 @@ export default function TreeRenderer({ const minHeight = getRowMinHeight(node.children); return ( 0 && { minHeight }]}> - + ); } @@ -130,18 +138,18 @@ export default function TreeRenderer({ /** Render every child of a container, each told where it sits among them. */ function TreeRendererChildren({ - children + nodes }: { - children: readonly TableRenderNode[]; + nodes: readonly TableRenderNode[]; }) { return ( <> - {children.map((child, index) => ( + {nodes.map((child, index) => ( ))} diff --git a/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts b/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts index bd2e693..d8ad293 100644 --- a/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/useHtmlTableCellProps.test.ts @@ -70,35 +70,23 @@ function cellStyleFor( } describe('useHtmlTableCellProps', () => { - it.each(['td', 'th'])( - 'uses an explicit %s height as a minimum by default', - (tag) => { - const style = cellStyleFor(`<${tag} style="height:48px">A`); - expect(style.minHeight).toBe(48); - expect(style).not.toHaveProperty('height'); - } - ); + // A cell height is always a minimum, whatever `growBeyondHeight` says: that + // option governs the *table* box, which is why the hook never reads it. The + // two cases below used to pass it either way and assert the same thing. + it.each(['td', 'th'])('uses an explicit %s height as a minimum', (tag) => { + const style = cellStyleFor(`<${tag} style="height:48px">A`); + expect(style.minHeight).toBe(48); + expect(style).not.toHaveProperty('height'); + }); it('allows content to outgrow a configured cell height', () => { const style = cellStyleFor('Wrapping content', { - growBeyondHeight: false, getStyleForCell: () => ({ height: 24 }) }); expect(style.minHeight).toBe(24); expect(style).not.toHaveProperty('height'); }); - it.each(['td', 'th'])( - 'passes an explicit %s height as minHeight when growBeyondHeight is set', - (tag) => { - const style = cellStyleFor(`<${tag} style="height:48px">A`, { - growBeyondHeight: true - }); - expect(style.minHeight).toBe(48); - expect(style).not.toHaveProperty('height'); - } - ); - it.each([ ['top', 'flex-start'], ['baseline', 'flex-start'], diff --git a/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts b/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts index 5e6383a..f6724b9 100644 --- a/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts @@ -1,6 +1,6 @@ import { createTableTNode } from './utils'; import TableLayout from '../TableLayout'; -import { getSourceBlockStyle } from '../helpers/tableStyles'; +import { getSourceBlockStyle } from '../helpers/cellPadding'; describe('authored writing direction', () => { it('is recovered from the flow styles, where the processor files it', () => { diff --git a/packages/heuristic-table-plugin/src/createCellRenderer.ts b/packages/heuristic-table-plugin/src/createCellRenderer.ts new file mode 100644 index 0000000..1422d63 --- /dev/null +++ b/packages/heuristic-table-plugin/src/createCellRenderer.ts @@ -0,0 +1,27 @@ +import React from 'react'; +import { CustomBlockRenderer } from '@native-html/render'; +import useHtmlTableCellProps from './useHtmlTableCellProps'; + +/** + * Build the renderer for a table cell tag. + * + * @remarks + * `td` and `th` render identically — the difference between them is carried by + * the user-agent styles the engine has already resolved, not by anything this + * plugin does. They are built from one implementation so the two cannot drift, + * and keep separate names so React devtools still tells them apart. + */ +export default function createCellRenderer( + tagName: 'td' | 'th' +): CustomBlockRenderer { + const displayName = `${tagName === 'td' ? 'Td' : 'Th'}Renderer`; + const renderer: CustomBlockRenderer = function CellRenderer(props) { + return React.createElement( + props.TDefaultRenderer, + useHtmlTableCellProps(props) + ); + }; + Object.defineProperty(renderer, 'name', { value: displayName }); + (renderer as { displayName?: string }).displayName = displayName; + return renderer; +} diff --git a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts index b9c56dd..d509be4 100644 --- a/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts +++ b/packages/heuristic-table-plugin/src/helpers/TCellConstraintsComputer.ts @@ -2,7 +2,7 @@ import { ViewStyle } from 'react-native'; import { TNode } from '@native-html/render'; import { TCellConstraints } from '../shared-types'; import { getHorizontalInsets, getHorizontalMargins } from './measure'; -import { getPaintedBlockStyle } from './tableStyles'; +import { getPaintedBlockStyle } from './cellPadding'; import { clampWidth, resolveCssSize, @@ -88,20 +88,30 @@ export const DEFAULT_FONT_WEIGHT_COEFFS: FontWeightCoefficients = { normal: 1 }; +/** + * Stands in for the containing width where percentages are deliberately not + * resolved, so that no caller has to invent one. + * + * @remarks + * `resolveWidthConstraints` reads its containing width only to turn a + * percentage into pixels. With `resolvePercentages: false` it never does, so + * this value is never read — naming it says so, where a bare `0` looked like a + * width that had been forgotten. + */ +const UNUSED_CONTAINING_WIDTH = 0; + export default class TCellConstraintsComputer { // A computer belongs to one layout. Cell styles and available width can // change between its passes; descendant content and font coefficients cannot. private intrinsicConstraints = new WeakMap(); private baseFontCoeff: number; private fallbackFontSize: number; - private contentWidth: number; private fontWeightCoeffs: FontWeightCoefficients; constructor({ baseFontCoeff, fallbackFontSize, - fontWeightCoeffs, - contentWidth + fontWeightCoeffs }: { baseFontCoeff?: number; fallbackFontSize?: number; @@ -110,18 +120,12 @@ export default class TCellConstraintsComputer { * {@link DEFAULT_FONT_WEIGHT_COEFFS}. */ fontWeightCoeffs?: FontWeightCoefficients; - /** - * The width of the table's containing block, against which percentage - * widths are resolved. - */ - contentWidth?: number; }) { this.baseFontCoeff = baseFontCoeff ?? 0.65; this.fallbackFontSize = fallbackFontSize ?? 14; this.fontWeightCoeffs = fontWeightCoeffs ? { ...DEFAULT_FONT_WEIGHT_COEFFS, ...fontWeightCoeffs } : DEFAULT_FONT_WEIGHT_COEFFS; - this.contentWidth = contentWidth ?? 0; } private getTextCoeff(ch: TextChunkStats): number { @@ -183,9 +187,11 @@ export default class TCellConstraintsComputer { * lowest priority. */ private resolveBlockWidth(tnode: TNode, style?: ViewStyle): number | null { - return resolveImposedWidth(tnode, this.contentWidth, { - // Cell percentages are preferences reconciled during column distribution. - // Descendant percentages depend on the as-yet unknown cell content box. + return resolveImposedWidth(tnode, UNUSED_CONTAINING_WIDTH, { + // Cell percentages are preferences reconciled during column + // distribution; descendant percentages depend on the as-yet unknown cell + // content box. Neither is resolved here, which is why no containing + // width is needed. resolvePercentages: false, style }); @@ -248,10 +254,15 @@ export default class TCellConstraintsComputer { return constraints; } + /** + * @param contentWidth - The width the table offers its columns, which the + * cell's own percentage and `max-width` resolve against. Required: it + * changes between measurement passes, so there is no sensible default. + */ computeCellConstraints( tnode: TNode, style: ViewStyle = getPaintedBlockStyle(tnode), - contentWidth = this.contentWidth + contentWidth = 0 ): TCellConstraints { const intrinsic = this.measureIntrinsicConstraints(tnode); const { blockWidth } = intrinsic; diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts index 426cb98..95c10a6 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/TCellConstraintsComputer.test.ts @@ -3,7 +3,7 @@ import TCellConstraintsComputer, { FontWeightCoefficients } from '../TCellConstraintsComputer'; import { TCellConstraints } from '../../shared-types'; -import { DEFAULT_CELL_PADDING } from '../tableStyles'; +import { DEFAULT_CELL_PADDING } from '../cellPadding'; import { createCellTNode } from '../../__tests__/utils'; import { ViewStyle } from 'react-native'; @@ -27,11 +27,12 @@ function constraintsFor( fontWeightCoeffs?: FontWeightCoefficients ): TCellConstraints { return new TCellConstraintsComputer({ - contentWidth, baseFontCoeff: BASE_FONT_COEFF, fontWeightCoeffs }).computeCellConstraints( - createCellTNode(`${cellMarkup}
`) + createCellTNode(`${cellMarkup}
`), + undefined, + contentWidth ); } @@ -56,9 +57,8 @@ describe('TCellConstraintsComputer', () => { cases.forEach(([style, width], i) => { expect(results[i]).toEqual( new TCellConstraintsComputer({ - baseFontCoeff: 0.5, - contentWidth: width - }).computeCellConstraints(cell, style) + baseFontCoeff: 0.5 + }).computeCellConstraints(cell, style, width) ); }); expect(results[0]!.percentWidth).toBe(0.4); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/cellStyles.test.ts similarity index 98% rename from packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts rename to packages/heuristic-table-plugin/src/helpers/__tests__/cellStyles.test.ts index 492369a..e1255e3 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/tableStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/cellStyles.test.ts @@ -1,11 +1,8 @@ import { I18nManager } from 'react-native'; -import { - getCollapsedCellBorderStyle, - getCollapsedTableBorderStyle, - getDefaultCellPaddingStyle, - resolveBorderCollapse, - resolveCellVerticalAlign -} from '../tableStyles'; +import { resolveBorderCollapse } from '../borderModel'; +import { getDefaultCellPaddingStyle } from '../cellPadding'; +import { resolveCellVerticalAlign } from '../cellVerticalAlign'; +import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle } from '../collapseBorders'; import buildTableGrid from '../buildTableGrid'; import { createCellTNode, createTableTNode } from '../../__tests__/utils'; @@ -23,7 +20,7 @@ function displayFor(html: string) { return { display, table }; } -describe('table styles', () => { +describe('cell styles', () => { describe('vertical alignment', () => { it('declares nothing when the cell inherits the HTML default', () => { expect( diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts index e642f3f..89a49e6 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/computeColumnWidths.test.ts @@ -126,4 +126,97 @@ describe('computeColumnWidths', () => { ); expect(widths).toEqual([200, 200]); }); + + describe('declared column widths', () => { + /** + * Two columns holding the *same* 40px of content, where the first spends + * an extra `extraSpacing` on padding and borders — so its box is wider by + * exactly that much and nothing else differs. + */ + const sameContentDifferentSpacing = (extraSpacing: number) => + computeColumnWidths( + makeDisplay( + [extraSpacing, 0].map((horizontalSpace, x) => ({ + x, + y: 0, + constraints: { + minWidth: 40 + horizontalSpace, + maxWidth: 40 + horizontalSpace, + contentDensity: 1, + horizontalSpace + } + })), + { contentWidth: 200, forceStretch: true } + ) + ); + + it('shares surplus over content, not over the spacing a column carries', () => { + // Weighting by the whole box would give the first column the larger + // share purely for painting one more border edge — which is exactly the + // bookkeeping difference the collapsing model creates between cells, and + // exactly what must not become a visible width difference. + const [first, second] = sameContentDifferentSpacing(10); + expect(first! + second!).toBeCloseTo(200); + // Equal content in, equal content out; the box differs by the spacing. + expect(first! - 10).toBeCloseTo(second!); + expect(first!).toBeCloseTo(105); + expect(second!).toBeCloseTo(95); + }); + + it('splits surplus evenly when every column carries the same spacing', () => { + const [first, second] = sameContentDifferentSpacing(0); + expect(first).toBeCloseTo(100); + expect(second).toBeCloseTo(100); + }); + + it('gives a percentage column its share of the assignable width', () => { + const widths = computeColumnWidths( + makeDisplay( + [0, 1].map((x) => ({ + x, + y: 0, + constraints: { minWidth: 10, maxWidth: 20, contentDensity: 1 } + })), + { contentWidth: 400, forceStretch: true } + ), + [ + { + width: null, + percent: 0.75, + minWidth: 0, + maxWidth: null, + maxPercent: null + }, + null + ] + ); + expect(widths[0]).toBeCloseTo(300); + expect(widths[0]! + widths[1]!).toBeCloseTo(400); + }); + + it('never grows a column past a declared max-width', () => { + const widths = computeColumnWidths( + makeDisplay( + [0, 1].map((x) => ({ + x, + y: 0, + constraints: { minWidth: 10, maxWidth: 20, contentDensity: 1 } + })), + { contentWidth: 400, forceStretch: true } + ), + [ + { + width: null, + percent: null, + minWidth: 0, + maxWidth: 60, + maxPercent: null + }, + null + ] + ); + expect(widths[0]).toBeLessThanOrEqual(60); + expect(widths[0]! + widths[1]!).toBeCloseTo(400); + }); + }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts index 518c3e2..f618562 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/createRenderTree.test.ts @@ -12,7 +12,7 @@ function makeRenderTree(html: string, columnWidths: number[]) { const tnode = createTableTNode(html); // The render tree is built from coordinates and widths alone, so the grid // needs no measurement pass to produce one. - return createRenderTree(makeTableCells(buildTableGrid(tnode), columnWidths)); + return createRenderTree(makeTableCells(buildTableGrid(tnode), columnWidths, 0)); } function rowContainer( diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts index 0213a55..174e6b3 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/makeRows.test.ts @@ -24,4 +24,35 @@ describe('makeRows', () => { const cells = Array.from({ length: 100 }, (_, y) => cell(y)); expect(makeRows(cells).flat()).toMatchObject(cells); }); + + it('groups cells by row, in ascending row order', () => { + // Deliberately out of order, and with several cells per row: the previous + // implementation happened to come back sorted because `y` stringifies to + // an array index, which is a property of the keys rather than something + // this function stated. + const rows = makeRows([ + cell(2, 0), + cell(0, 0), + cell(1, 0), + cell(0, 1), + cell(2, 1) + ]); + expect(rows.map((row) => row.map((c) => [c.x, c.y]))).toEqual([ + [ + [0, 0], + [1, 0] + ], + [[0, 1]], + [ + [0, 2], + [1, 2] + ] + ]); + }); + + it('keeps cells of one row in the order they were given', () => { + const rows = makeRows([cell(0, 2), cell(0, 0), cell(0, 1)]); + expect(rows).toHaveLength(1); + expect(rows[0]!.map((c) => c.x)).toEqual([2, 0, 1]); + }); }); diff --git a/packages/heuristic-table-plugin/src/helpers/borderModel.ts b/packages/heuristic-table-plugin/src/helpers/borderModel.ts new file mode 100644 index 0000000..0ce329a --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/borderModel.ts @@ -0,0 +1,36 @@ +import { TNode } from '@native-html/render'; +import { getInlineStyleValue } from './inlineStyle'; + +export type BorderCollapse = 'collapse' | 'separate'; + +/** + * Resolve whether a table uses the collapsing border model. + * + * Inline `border-collapse` is not part of React Native styles, so it must be + * read from the source DOM. The `rules` attribute also implies collapsed + * borders in the HTML rendering rules. + */ +export function resolveBorderCollapse( + tnode: TNode, + configuredValue?: BorderCollapse +): boolean { + if (configuredValue) { + return configuredValue === 'collapse'; + } + const ownValue = getInlineStyleValue(tnode, 'border-collapse'); + if (ownValue === 'collapse' || ownValue === 'separate') { + return ownValue === 'collapse'; + } + if (tnode.attributes.rules) { + return true; + } + // border-collapse is inherited. Only inline declarations are available to + // the plugin after unsupported web-only properties have been processed. + for (let parent = tnode.parent; parent; parent = parent.parent) { + const inheritedValue = getInlineStyleValue(parent, 'border-collapse'); + if (inheritedValue === 'collapse' || inheritedValue === 'separate') { + return inheritedValue === 'collapse'; + } + } + return false; +} diff --git a/packages/heuristic-table-plugin/src/helpers/borderSpacingGeometry.ts b/packages/heuristic-table-plugin/src/helpers/borderSpacingGeometry.ts new file mode 100644 index 0000000..35be667 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/borderSpacingGeometry.ts @@ -0,0 +1,32 @@ +/** + * How `border-spacing` is spent across a table, in one place. + * + * @remarks + * These two figures and the renderer must agree, and they are derived rather + * than restated: {@link TreeRenderer} realises the same algebra as a + * `paddingHorizontal` on the table root plus a `marginEnd` on every cell that + * is not in the last column, which is exactly + * `2 * spacing + (columns - 1) * spacing` — the total below. + */ + +/** + * The gap a cell spanning `lenX` columns swallows. + * + * @remarks + * A spanning cell covers the boundaries *between* the columns it spans, so its + * own box absorbs that spacing instead of the table painting it. + */ +export function spanInternalSpacing(lenX: number, spacing: number): number { + return Math.max(0, lenX - 1) * spacing; +} + +/** + * The spacing a table spends in total: one gap between each pair of adjacent + * columns, plus one at each outer edge. + * + * @param maxX - The last occupied column index, so the table has `maxX + 1` + * columns and `maxX + 2` gaps. + */ +export function totalHorizontalSpacing(maxX: number, spacing: number): number { + return (maxX + 2) * spacing; +} diff --git a/packages/heuristic-table-plugin/src/helpers/boxSides.ts b/packages/heuristic-table-plugin/src/helpers/boxSides.ts new file mode 100644 index 0000000..4bb0784 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/boxSides.ts @@ -0,0 +1,27 @@ +import { I18nManager, ViewStyle } from 'react-native'; + +/** The four physical edges of a box, spelled as React Native style suffixes. */ +export type BoxSide = 'Bottom' | 'Left' | 'Right' | 'Top'; + +export const BOX_SIDES = ['Top', 'Right', 'Bottom', 'Left'] as const; + +/** + * Whether a style resolves its logical edges right-to-left. + * + * @remarks + * An explicit `direction` wins; otherwise the app-wide setting decides, which + * is what Yoga itself does with an unset direction. + */ +export function isRTL(style: ViewStyle): boolean { + return ( + style.direction === 'rtl' || + (style.direction !== 'ltr' && I18nManager.isRTL) + ); +} + +/** The logical edge a physical horizontal side maps to, or `null` vertically. */ +export function logicalSideOf(side: BoxSide, rtl: boolean): 'End' | 'Start' | null { + if (side === 'Left') return rtl ? 'End' : 'Start'; + if (side === 'Right') return rtl ? 'Start' : 'End'; + return null; +} diff --git a/packages/heuristic-table-plugin/src/helpers/cellPadding.ts b/packages/heuristic-table-plugin/src/helpers/cellPadding.ts new file mode 100644 index 0000000..1b66e0a --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/cellPadding.ts @@ -0,0 +1,225 @@ +import { ViewStyle } from 'react-native'; +import { TNode } from '@native-html/render'; +import { BoxSide, BOX_SIDES, logicalSideOf } from './boxSides'; + +/** + * The padding HTML's user-agent stylesheet gives a table cell. + * + * @remarks + * `td, th { padding: 1px }`, per the + * {@link https://html.spec.whatwg.org/multipage/rendering.html#tables-2 | HTML rendering rules}. + * Being a user-agent declaration, it is outranked by any author padding, side + * by side: a cell which declares `padding-left` alone still gets the default + * on the three sides it left untouched. + * + * @public + */ +export const DEFAULT_CELL_PADDING = 1; + +/** + * The padding properties that can set one side, in the order Yoga resolves + * them: the first one a style declares is the one that wins. + * + * @remarks + * Source CSS always reaches the plugin expanded per side, but + * {@link HeuristicTablePluginConfig.getStyleForCell} is hand-written React + * Native style, where any shorthand is fair game — and a shorthand cannot + * simply be overwritten, because Yoga resolves a side against its own edge and + * only falls back to the `padding` edge. + * + * This is the single statement of that precedence. `getHorizontalInsets` + * reads it in order to find the value a side takes; + * {@link getDefaultCellPaddingStyle} reads {@link PADDING_DECLARERS}, the + * direction-agnostic union of it, to ask merely whether a side was declared. + */ +export function paddingSourcesFor( + side: BoxSide, + rtl: boolean +): readonly (keyof ViewStyle)[] { + const logical = logicalSideOf(side, rtl); + if (logical) { + return [ + `paddingInline${logical}`, + `padding${logical}`, + `padding${side}`, + 'paddingInline', + 'paddingHorizontal', + 'padding' + ] as (keyof ViewStyle)[]; + } + return [ + `padding${side}`, + `paddingBlock${side === 'Top' ? 'Start' : 'End'}`, + 'paddingBlock', + 'paddingVertical', + 'padding' + ] as (keyof ViewStyle)[]; +} + +/** + * Every property which declares padding on a given side, in either writing + * direction. + * + * @remarks + * The union of both directions on purpose. Which physical side a + * writing-direction keyword lands on is not known when merely asking whether + * an author declared a side, and reserving both is the harmless choice: it + * withholds a user-agent default rather than fighting an author declaration. + */ +const PADDING_DECLARERS = BOX_SIDES.reduce( + (declarers, side) => { + declarers[side] = [ + ...new Set([ + ...paddingSourcesFor(side, false), + ...paddingSourcesFor(side, true) + ]) + ]; + return declarers; + }, + {} as Record +); + +/** + * The source block style of a node, with its writing direction folded in. + * + * @remarks + * `direction` is a flow property, not a retained box one: the CSS processor + * files it under `nativeBlockFlow` (`makePropertiesValidators`, the sole + * member of the block-flow model), and unlike `nativeBlockRet` that bag is + * inherited — a cell of a `` carries `rtl` + * without declaring it. + * + * Every pass which resolves a *logical* edge has to see it: {@link isRTL} + * here, and `getHorizontalInsets` in `measure`. Reading `nativeBlockRet` alone + * makes an authored `direction` invisible, so an RTL table resolves its + * logical borders and padding onto the wrong physical side. + */ +export function getSourceBlockStyle(tnode: TNode): ViewStyle { + const style = tnode.styles.nativeBlockRet; + const direction = tnode.styles.nativeBlockFlow?.direction; + return direction == null ? style : { ...style, direction }; +} + +/** + * Whether a node is a table cell, and so subject to the cell rules of the + * user-agent stylesheet. + */ +export function isTableCell(tnode: TNode): boolean { + return tnode.tagName === 'td' || tnode.tagName === 'th'; +} + +/** + * Everything a node is painted with, the user-agent cell rules included. + * + * @remarks + * `nativeBlockRet` holds source CSS alone, so a cell which declares no padding + * appears to have none while the renderer gives it + * {@link DEFAULT_CELL_PADDING}. Any pass which measures a box against what + * ends up on screen has to reconcile the two here first. + */ +export function getPaintedBlockStyle( + tnode: TNode +): TNode['styles']['nativeBlockRet'] { + const style = getSourceBlockStyle(tnode); + // The same cascade the renderer applies, with no config and no border: a + // cell measured against anything else would not match what it paints. + return isTableCell(tnode) ? composeCellStyle(style, null) : style; +} + +/** + * The padding a table cell owes to {@link DEFAULT_CELL_PADDING} alone. + * + * @param declaredStyles - Everything the cell declares padding in, source CSS + * and {@link HeuristicTablePluginConfig.getStyleForCell} alike. A side any of + * them covers is left out of the result. + * + * @remarks + * The result is expanded per side rather than left as a `padding` shorthand, + * so that the sides an author did declare stay untouched. + */ +export function getDefaultCellPaddingStyle( + ...declaredStyles: (ViewStyle | null | undefined)[] +): ViewStyle { + const resolvedStyle: ViewStyle = {}; + for (const side of BOX_SIDES) { + const isDeclared = declaredStyles.some((style) => + style + ? PADDING_DECLARERS[side].some((property) => style[property] != null) + : false + ); + if (!isDeclared) { + Object.assign(resolvedStyle, { + [`padding${side}`]: DEFAULT_CELL_PADDING + }); + } + } + return resolvedStyle; +} + +/** + * Expand callback shorthands so resolved source longhands cannot mask them. + * + * @remarks + * Every shorthand Yoga resolves *after* a per-side edge has to be expanded + * here, the logical `paddingInline` / `paddingBlock` pair included: a cell + * declaring `padding-left` in its source CSS reaches the merge as a longhand, + * which would otherwise win on that one side and leave the callback's + * shorthand painting the other three — the opposite of the documented rule + * that callback padding replaces source padding outright. + * + * The per-side logical properties (`paddingStart`, `paddingInlineEnd` and + * friends) need no expansion: Yoga already resolves them ahead of the physical + * longhands, so they mask the source rather than being masked by it. + */ +export function resolveConfiguredCellStyle( + style: ViewStyle | null | undefined +): ViewStyle | null { + if (!style) return null; + // The shorthands Yoga resolves *after* a per-side edge, in the same order + // `paddingSourcesFor` states: anything later than `padding` in that + // list would otherwise be masked by a source longhand. + const horizontal = + style.paddingInline ?? style.paddingHorizontal ?? style.padding; + const vertical = style.paddingBlock ?? style.paddingVertical ?? style.padding; + return { + ...(horizontal != null + ? { paddingLeft: horizontal, paddingRight: horizontal } + : null), + ...(vertical != null + ? { paddingTop: vertical, paddingBottom: vertical } + : null), + ...style + }; +} + +/** + * Merge everything a cell is painted with, in CSS origin order. + * + * @remarks + * Shared by measurement and rendering so the two cannot disagree about what a + * cell looks like. Lives here rather than in its own module because + * {@link getPaintedBlockStyle} is defined in terms of it, and + * {@link getDefaultCellPaddingStyle} in terms of that — three names for one + * cascade, which would be a cycle if they sat in separate files. + */ +export default function composeCellStyle( + source: ViewStyle, + configured: ViewStyle | null, + { + border = null, + rendererDefaults = {}, + paddingSource = source + }: { + border?: ViewStyle | null; + rendererDefaults?: ViewStyle; + paddingSource?: ViewStyle; + } = {} +): ViewStyle { + return { + ...getDefaultCellPaddingStyle(paddingSource, configured), + ...source, + ...rendererDefaults, + ...configured, + ...border + }; +} diff --git a/packages/heuristic-table-plugin/src/helpers/cellVerticalAlign.ts b/packages/heuristic-table-plugin/src/helpers/cellVerticalAlign.ts new file mode 100644 index 0000000..7dace3f --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/cellVerticalAlign.ts @@ -0,0 +1,93 @@ +import { ViewStyle } from 'react-native'; +import { TNode } from '@native-html/render'; +import { getInlineStyleValue } from './inlineStyle'; + +export type CellVerticalAlign = 'baseline' | 'bottom' | 'middle' | 'top'; + +function normalizeVerticalAlign(value: string): CellVerticalAlign | null { + switch (value.toLowerCase()) { + case 'top': + case 'middle': + case 'bottom': + case 'baseline': + return value.toLowerCase() as CellVerticalAlign; + case 'initial': + case 'unset': + return 'baseline'; + case 'inherit': + case 'revert': + case 'revert-layer': + return null; + default: + // Lengths, percentages and the inline-only vertical-align keywords are + // treated as baseline for table cells by CSS. + return 'baseline'; + } +} + +/** + * The alignment HTML's user-agent stylesheet gives a table cell. + * + * @remarks + * Row groups and direct table rows are aligned to the middle, and rows and + * cells inherit it. Being a user-agent declaration, it is outranked by any + * author style that resolves to the same native property. + * + * @public + */ +export const DEFAULT_CELL_VERTICAL_ALIGN: CellVerticalAlign = 'middle'; + +/** + * Resolve the vertical alignment a native table cell should emulate. + * + * The CSS processor intentionally drops `vertical-align` because React Native + * cannot consume it directly, so table renderers recover the value from inline + * CSS and the legacy `valign` attribute here. + * + * @returns The declared alignment, or `null` when the cell inherits nothing + * but {@link DEFAULT_CELL_VERTICAL_ALIGN}. Callers need the distinction: the + * default may not overwrite an author `justify-content`, whereas a declared + * alignment must. + */ +export function resolveCellVerticalAlign( + tnode: TNode +): CellVerticalAlign | null { + for ( + let current: TNode | null = tnode; + current && current.tagName !== 'table'; + current = current.parent + ) { + const inlineValue = getInlineStyleValue(current, 'vertical-align'); + if (inlineValue) { + const normalized = normalizeVerticalAlign(inlineValue); + if (normalized) { + return normalized; + } + } + const attributeValue = current.attributes.valign; + if (attributeValue) { + const normalized = normalizeVerticalAlign(attributeValue); + if (normalized) { + return normalized; + } + } + } + return null; +} + +/** + * How a table cell emulates `vertical-align` in a column flex container. + * + * @remarks + * `baseline` has no native equivalent for a block box, and a cell's first line + * box sits at its top, so it collapses onto the same alignment as `top`. + */ +export const justifyContentForVerticalAlign: Record< + CellVerticalAlign, + NonNullable +> = { + baseline: 'flex-start', + bottom: 'flex-end', + middle: 'center', + top: 'flex-start' +}; diff --git a/packages/heuristic-table-plugin/src/helpers/collapseBorders.ts b/packages/heuristic-table-plugin/src/helpers/collapseBorders.ts new file mode 100644 index 0000000..54bceb0 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/collapseBorders.ts @@ -0,0 +1,302 @@ +import { ViewStyle } from 'react-native'; +import { DisplayCell, TableCell, TableGrid } from '../shared-types'; +import type { CellNeighbours } from './indexCellNeighbours'; +import { BOX_SIDES, BoxSide, isRTL, logicalSideOf } from './boxSides'; +import { getSourceBlockStyle } from './cellPadding'; + +type BorderSide = BoxSide; + +interface BorderCandidate { + color: ViewStyle['borderColor']; + fromCell: boolean; + style: NonNullable; + width: number; +} + +const borderStylePriority: Record = { + dotted: 0, + dashed: 1, + solid: 2 +}; + +function borderCandidate( + style: ViewStyle, + side: BorderSide, + fromCell: boolean +): BorderCandidate { + const logicalSide = logicalSideOf(side, isRTL(style)); + // The CSS processor always expands `border` per side, but + // `getStyleForCell` is hand-written and the shorthand is the natural way to + // reach for a border there, so fall back to it. An explicit per-side `0` + // still wins, as it does in React Native. + const width = ((logicalSide + ? style[`border${logicalSide}Width`] + : undefined) ?? + style[`border${side}Width`] ?? + style.borderWidth) as number | undefined; + const color = ((logicalSide + ? style[`border${logicalSide}Color`] + : undefined) ?? + style[`border${side}Color`] ?? + style.borderColor) as ViewStyle['borderColor']; + return { + color: color ?? 'black', + fromCell, + style: style.borderStyle ?? 'solid', + width: typeof width === 'number' ? width : 0 + }; +} + +/** Prevent logical edges from overriding the resolved physical borders. */ +function clearLogicalBorders(style: ViewStyle): ViewStyle { + const cleared: ViewStyle = {}; + for (const key of [ + 'borderStartWidth', + 'borderEndWidth', + 'borderStartColor', + 'borderEndColor' + ] as const) { + if (style[key] != null) Object.assign(cleared, { [key]: undefined }); + } + return cleared; +} + +function resolveBorderConflict( + winner: BorderCandidate, + candidate: BorderCandidate +): BorderCandidate { + if (candidate.width !== winner.width) { + return candidate.width > winner.width ? candidate : winner; + } + const candidatePriority = borderStylePriority[candidate.style]; + const winnerPriority = borderStylePriority[winner.style]; + if (candidatePriority !== winnerPriority) { + return candidatePriority > winnerPriority ? candidate : winner; + } + // With otherwise equal borders, CSS gives a cell precedence over the table. + return candidate.fromCell && !winner.fromCell ? candidate : winner; +} + +/** + * A cell as the collapsing border model sees it: where it sits in the matrix, + * and the node its source styles come from. + */ +type CollapsibleCell = Pick; + +/** + * The matrix a collapsed border is resolved over. + * + * @remarks + * `maxX` and `maxY` come from the display rather than from the cells, so that + * this agrees with {@link getCollapsedCellBorderStyle} on which cells are at + * an edge. The two disagree for a `rowspan` that overruns the last row: the + * table does not grow rows to fit it, so the cell is clipped and the last row + * the display laid out stays the bottom edge. + */ +type CollapsibleMatrix = { + cells: readonly C[]; +} & Pick; + +/** + * Whether a cell sits against one of the table's own edges. + * + * @remarks + * Shared by both collapsing passes on purpose. The wrapper resolves an outer + * border from the cells at an edge, and each cell then decides whether that + * same edge is its own; the two must agree, or a boundary is painted twice or + * not at all. + * + * A span that overruns the matrix is clipped to it rather than growing the + * table, so it sits at the edge it overran — hence `>=` rather than `===`. + */ +function isAtOuterEdge( + cell: Pick, + side: BorderSide, + { maxX, maxY }: Pick +): boolean { + switch (side) { + case 'Top': + return cell.y === 0; + case 'Right': + return cell.x + cell.lenX - 1 >= maxX; + case 'Bottom': + return cell.y + cell.lenY - 1 >= maxY; + case 'Left': + return cell.x === 0; + } +} + +function cellsAtOuterEdge( + { cells, maxX, maxY }: CollapsibleMatrix, + side: BorderSide +): readonly C[] { + return cells.filter((cell) => isAtOuterEdge(cell, side, { maxX, maxY })); +} + +function sourceCellStyle(cell: CollapsibleCell): ViewStyle { + return getSourceBlockStyle(cell.tnode); +} + +/** + * Resolve each outer collapsed border between the table and its edge cells. + * + * React Native cannot render different border segments along one side of a + * View, so the strongest cell candidate is used for that complete side. This + * still preserves the central CSS conflict rules: wider borders win, then + * stronger styles, then cells over the table. + * + * @param matrix - See {@link CollapsibleMatrix}. + * @param tableStyle - The table source style. Each pass starts from this + * rather than a previously collapsed result, so a callback can remove a + * source cell border as well as strengthen it. + * @param getCellStyle - Everything an edge cell paints with. Defaults to its + * source CSS alone. + */ +export function getCollapsedTableBorderStyle( + matrix: CollapsibleMatrix, + tableStyle: ViewStyle, + getCellStyle: (cell: C) => ViewStyle = sourceCellStyle +): ViewStyle { + const resolvedStyle: ViewStyle = clearLogicalBorders(tableStyle); + let strongestStyle: BorderCandidate['style'] | null = null; + for (const side of BOX_SIDES) { + const winner = cellsAtOuterEdge(matrix, side).reduce( + (currentWinner, cell) => + resolveBorderConflict( + currentWinner, + borderCandidate(getCellStyle(cell), side, true) + ), + borderCandidate(tableStyle, side, false) + ); + Object.assign(resolvedStyle, { + [`border${side}Width`]: winner.width, + [`border${side}Color`]: winner.color + }); + if ( + winner.width > 0 && + (strongestStyle === null || + borderStylePriority[winner.style] > borderStylePriority[strongestStyle]) + ) { + strongestStyle = winner.style; + } + } + resolvedStyle.borderStyle = strongestStyle ?? 'solid'; + return resolvedStyle; +} + +/** + * Which boundaries of the table a cell sits against. + * + * @remarks + * `tableBorderStyle` is the wrapper edge {@link getCollapsedTableBorderStyle} + * resolved, and is consulted rather than assumed: a side the wrapper leaves + * bare has to stay with the cell. + */ +export interface CollapsedCellEdges { + maxX: number; + maxY: number; + tableBorderStyle: ViewStyle | null; + /** + * The cells sharing this cell's trailing and bottom boundary, from + * {@link indexCellNeighbours}. + * + * @remarks + * Absent when the caller has no matrix to index — a `td` renderer reached + * outside this plugin's table — in which case the cell keeps its own + * borders rather than resolving them against neighbours it cannot see. + */ + neighbours?: CellNeighbours; + /** + * Everything a neighbouring cell paints with. Required alongside + * `neighbours`, which is the only thing that consumes it. + */ + getCellStyle?: (cell: CollapsibleCell) => ViewStyle; +} + +/** + * Draw every shared cell boundary exactly once. + * + * @param cell - The cell's position in the table matrix. + * @param cellStyle - Everything the cell paints with, source CSS and + * {@link HeuristicTablePluginConfig.getStyleForCell} alike. + * @param edges - See {@link CollapsedCellEdges}. + * + * @remarks + * Each cell owns its trailing and bottom boundary, and the table wrapper owns + * the four outer ones it resolved against the edge cells. This mirrors the + * visible result of the collapsing model for the border styles React Native + * can render, without changing the flex geometry used for row and col spans. + * + * Shared boundaries compare the actual adjacent cells. Where spans bring + * several neighbours against one side, the strongest candidate paints that + * whole side; a native View cannot paint differently styled border segments. + */ +export function getCollapsedCellBorderStyle( + cell: Pick, + cellStyle: ViewStyle, + { + maxX, + maxY, + tableBorderStyle, + neighbours, + getCellStyle + }: CollapsedCellEdges +): ViewStyle { + const resolvedStyle: ViewStyle = clearLogicalBorders(cellStyle); + const isOuterEdge = (side: BorderSide) => + isAtOuterEdge(cell, side, { maxX, maxY }); + const isPaintedByTable = (side: BorderSide) => { + const width = tableBorderStyle?.[`border${side}Width`]; + return typeof width === 'number' && width > 0; + }; + let strongestStyle: BorderCandidate['style'] | null = null; + const paint = (side: BorderSide, candidate: BorderCandidate | null) => { + if (!candidate || candidate.width === 0) { + Object.assign(resolvedStyle, { [`border${side}Width`]: 0 }); + return; + } + if ( + strongestStyle === null || + borderStylePriority[candidate.style] > borderStylePriority[strongestStyle] + ) { + strongestStyle = candidate.style; + } + Object.assign(resolvedStyle, { + [`border${side}Width`]: candidate.width, + [`border${side}Color`]: candidate.color + }); + }; + const ownBorder = (side: BorderSide) => + borderCandidate(cellStyle, side, true); + const keepOuterBorder = (side: BorderSide) => + isPaintedByTable(side) ? null : ownBorder(side); + // A leading boundary is always drawn by the neighbour that precedes it, + // except on the outside where there is no neighbour to draw it. + paint('Top', isOuterEdge('Top') ? keepOuterBorder('Top') : null); + paint('Left', isOuterEdge('Left') ? keepOuterBorder('Left') : null); + for (const [side, opposite] of [ + ['Right', 'Left'], + ['Bottom', 'Top'] + ] as const) { + paint( + side, + isOuterEdge(side) + ? keepOuterBorder(side) + : (neighbours?.[side] ?? []).reduce( + (winner, neighbour) => + resolveBorderConflict( + winner, + borderCandidate( + (getCellStyle ?? sourceCellStyle)(neighbour), + opposite, + true + ) + ), + ownBorder(side) + ) + ); + } + if (strongestStyle !== null) resolvedStyle.borderStyle = strongestStyle; + return resolvedStyle; +} + diff --git a/packages/heuristic-table-plugin/src/helpers/composeCellStyle.ts b/packages/heuristic-table-plugin/src/helpers/composeCellStyle.ts deleted file mode 100644 index 64b32f3..0000000 --- a/packages/heuristic-table-plugin/src/helpers/composeCellStyle.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ViewStyle } from 'react-native'; -import { getDefaultCellPaddingStyle } from './tableStyles'; - -/** Shared precedence for measurement and rendering; config is normalized first. */ -export default function composeCellStyle( - source: ViewStyle, - configured: ViewStyle | null, - { - border = null, - rendererDefaults = {}, - paddingSource = source - }: { - border?: ViewStyle | null; - rendererDefaults?: ViewStyle; - paddingSource?: ViewStyle; - } = {} -): ViewStyle { - return { - ...getDefaultCellPaddingStyle(paddingSource, configured), - ...source, - ...rendererDefaults, - ...configured, - ...border - }; -} diff --git a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts index 8ee330a..053418a 100644 --- a/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts +++ b/packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts @@ -7,14 +7,6 @@ import sum from './sum'; /** Below this many pixels a leftover is not worth another distribution pass. */ const EPSILON = 1e-6; -function mapMinWidths(constraints: TColumnConstraints[]): number[] { - return constraints.map((c) => c.minWidth); -} - -function mapSpreads(constraints: TColumnConstraints[]): number[] { - return constraints.map((c) => c.spread); -} - /** * Share `total` across `weights`, proportionally. Falls back to an even share * when every weight is zero, so that no space is ever silently dropped. @@ -45,7 +37,7 @@ function interpolateWidths( Math.min(1, (targetWidth - lowerTotal) / (upperTotal - lowerTotal)) ); return lower.map( - (width, i) => width + ((upper[i] ?? width) - width) * progress + (width, i) => width + (upper[i]! - width) * progress ); } @@ -94,7 +86,7 @@ function addDistributedWidth( total: number, indexes: number[], caps: Array, - insets: number[] = [] + insets: number[] ): number[] { if (indexes.length === 0 || total <= 0) { return widths; @@ -102,7 +94,7 @@ function addDistributedWidth( const result = [...widths]; const hasRoom = (i: number) => { const cap = caps[i]; - return cap == null || (result[i] ?? 0) < cap; + return cap == null || result[i]! < cap; }; let candidates = indexes.filter(hasRoom); let remaining = total; @@ -113,13 +105,13 @@ function addDistributedWidth( // waits while the columns with content grow. It is not stranded: once // they have all reached their caps it is the only candidate left, and // `distribute` shares evenly when every weight is zero. - candidates.map((i) => Math.max(0, (result[i] ?? 0) - (insets[i] ?? 0))) + candidates.map((i) => Math.max(0, result[i]! - insets[i]!)) ); let consumed = 0; candidates.forEach((i, k) => { const cap = caps[i]; - const current = result[i] ?? 0; - const grown = current + (shares[k] ?? 0); + const current = result[i]!; + const grown = current + shares[k]!; const used = cap == null ? grown : Math.min(grown, cap); result[i] = used; consumed += used - current; @@ -151,24 +143,15 @@ export interface ColumnLayoutInput { forceStretch?: boolean; } -export default function computeColumnWidths( - { cells, assignableWidth, forceStretch }: ColumnLayoutInput, - declaredWidths: Array = [] -): number[] { - const contentWidth = assignableWidth; - const shouldStretch = !!forceStretch; - // The cell grid alone decides how many columns a table has. `col` and - // `colgroup` declarations past its last column describe columns that do not - // exist — honouring them would widen the table by the sum of widths nothing - // is ever rendered into, and hand it a scroll view to hold the surplus. - const columnConstraints = reduceColumnConstraints([...cells]); - if (columnConstraints.length === 0) { - return []; - } - // Cell percentages use the same sizing class as col/colgroup percentages, - // and `reduceColumnConstraints` has already spread each spanning cell's - // preference over the columns it covers. - const declarations = columnConstraints.map((constraints, i) => { +/** + * Fold each column's own percentage preference into the `col`/`colgroup` + * declarations, which share its sizing class. + */ +function mergeDeclarations( + columnConstraints: readonly TColumnConstraints[], + declaredWidths: Array +): Array { + return columnConstraints.map((constraints, i) => { const declared = declaredWidths[i]; const percent = constraints.percentWidth; if (percent == null) { @@ -183,67 +166,86 @@ export default function computeColumnWidths( percent: Math.max(declared?.percent ?? 0, percent) }; }); - // A `max-width` may be declared in either unit, and caps the column in - // whichever sizing class it ends up in. Percentage bounds travel unresolved - // so that the same declarations can be reused against another table width, - // and are turned into pixels here, once that width is known. - const caps = columnConstraints.map((_, i) => { - const declared = declarations[i]; - if (!declared) { - return null; - } - return lesserBound( - declared.maxWidth, - declared.maxPercent === null ? null : declared.maxPercent * contentWidth - ); - }); - for (const [i, constraints] of columnConstraints.entries()) { +} + +/** + * Resolve each column's upper bound to pixels. + * + * @remarks + * A `max-width` may be declared in either unit and caps the column in + * whichever sizing class it ends up in. Percentage bounds travel unresolved so + * that the same declarations can be reused against another table width, and + * become pixels here, once that width is known. + */ +function resolveCaps( + declarations: Array, + contentWidth: number +): Array { + return declarations.map((declared) => + declared + ? lesserBound( + declared.maxWidth, + declared.maxPercent === null + ? null + : declared.maxPercent * contentWidth + ) + : null + ); +} + +/** + * Apply declared widths and bounds to the intrinsic column constraints. + * + * @returns Fresh constraints; the input is left alone, so the same reduction + * may be reused for another candidate table width. + */ +function applyDeclaredBounds( + columnConstraints: readonly TColumnConstraints[], + declarations: Array, + caps: Array +): TColumnConstraints[] { + return columnConstraints.map((constraints, i) => { const declared = declarations[i]; if (!declared) { - continue; + return { ...constraints }; } - const cap = caps[i] ?? null; + const cap = caps[i]!; // Absolute column widths contribute to intrinsic minimum and preferred - // widths. Percentage widths remain unresolved until distribution below, - // and contribute only the absolute floor they were given. + // widths. Percentage widths remain unresolved until distribution, and + // contribute only the absolute floor they were given. const floor = clampWidth( declared.width ?? declared.minWidth, declared.minWidth, cap ); - if (floor > 0) { - constraints.minWidth = Math.max(constraints.minWidth, floor); - constraints.spread = Math.max(constraints.spread, floor); - } - // A `max-width` caps how far a column may grow, but never below the - // width its own content needs to be legible at all. - constraints.spread = clampWidth(constraints.spread, constraints.minWidth, cap); - } - const minWidths = mapMinWidths(columnConstraints); - const spreads = mapSpreads(columnConstraints); - const sumOfMinWidths = sum(minWidths); - if (contentWidth < sumOfMinWidths) { - // The table cannot fit: no column may go below the width it needs to hold - // its longest word, so the table overflows and `HTMLTable` scrolls it. - return minWidths; - } + const minWidth = + floor > 0 ? Math.max(constraints.minWidth, floor) : constraints.minWidth; + const spread = + floor > 0 ? Math.max(constraints.spread, floor) : constraints.spread; + return { + ...constraints, + minWidth, + // A `max-width` caps how far a column may grow, but never below the + // width its own content needs to be legible at all. + spread: clampWidth(spread, minWidth, cap) + }; + }); +} - // Keep percentage columns as a separate sizing class. This is the critical - // difference from resolving percentages to hard pixel minima up front: when - // the full percentage guess does not fit, browsers interpolate back toward - // the min-content guess while keeping the total at the assignable width. - const percentages = normalizePercentages( - declarations, - columnConstraints.length - ); - const percentageGuess = minWidths.map((minWidth, i) => { +/** + * The width each column would take if every percentage were honoured in full. + */ +function percentageGuessOf( + minWidths: number[], + percentages: Array, + caps: Array, + contentWidth: number +): number[] { + return minWidths.map((minWidth, i) => { const percent = percentages[i]; - if (percent === null || percent === undefined) { + if (percent == null) { return minWidth; } - // The fraction is resolved here rather than at extraction, so that the - // same declarations can be reused whenever the table is laid out again - // against another width. A `max-width` caps the share in the same pass. const cap = caps[i]; const preferred = percent * contentWidth; return Math.max( @@ -251,31 +253,26 @@ export default function computeColumnWidths( cap == null ? preferred : Math.min(preferred, cap) ); }); - const percentageGuessTotal = sum(percentageGuess); - if (contentWidth <= percentageGuessTotal) { - return interpolateWidths(minWidths, percentageGuess, contentWidth); - } - - // Next move non-percentage columns from min-content toward max-content. A - // percentage column keeps the width assigned by the percentage sizing guess. - const maxContentGuess = percentageGuess.map((width, i) => - percentages[i] == null ? Math.max(width, spreads[i] ?? 0) : width - ); - const maxContentGuessTotal = sum(maxContentGuess); - if (contentWidth <= maxContentGuessTotal) { - return interpolateWidths(percentageGuess, maxContentGuess, contentWidth); - } +} - // An auto-width table can shrink to its max-content size. An explicitly - // sized table (or forceStretch) must distribute the remaining assignable - // width so that the columns add up to the table width. - if (!shouldStretch) { - return maxContentGuess; - } - // The spacing each column carries, so that the surplus below is shared over - // content alone; `reduceColumnConstraints` reduced it the same way it - // reduced `minWidth`, which is the figure the spacing is held out of. - const columnInsets = columnConstraints.map((c) => c.horizontalSpace); +/** + * Share the width left over once every column sits at its max-content size. + * + * @remarks + * Each class of column is offered the surplus in turn, so that what one cannot + * take — every column in it held at its own `max-width` — falls through to the + * next rather than being dropped and leaving the table short of the width it + * was told to fill. Only when no column anywhere has room left does the table + * stay narrower than its assignable width. + */ +function distributeSurplus( + maxContentGuess: number[], + declarations: Array, + percentages: Array, + caps: Array, + columnInsets: number[], + contentWidth: number +): number[] { const allColumns = maxContentGuess.map((_, i) => i); // A column that declared a width of its own already has the width it asked // for; the surplus belongs to the ones that left it to the table to decide. @@ -284,11 +281,6 @@ export default function computeColumnWidths( return !declared || (declared.width === null && declared.percent === null); }); const percentColumns = allColumns.filter((i) => percentages[i] != null); - // Each class of column is offered the surplus in turn, so that what one - // cannot take — every column in it held at its own `max-width` — falls - // through to the next rather than being dropped and leaving the table short - // of the width it was told to fill. Only when no column anywhere has room - // left does the table stay narrower than its assignable width. let widths = maxContentGuess; for (const group of [autoColumns, percentColumns, allColumns]) { const leftover = contentWidth - sum(widths); @@ -299,3 +291,74 @@ export default function computeColumnWidths( } return widths; } + +/** + * Size the columns of a table, following the decision ladder of + * {@link https://www.w3.org/TR/CSS21/tables.html#auto-table-layout | CSS 2.1 §17.5.2.2}. + */ +export default function computeColumnWidths( + { cells, assignableWidth: contentWidth, forceStretch }: ColumnLayoutInput, + declaredWidths: Array = [] +): number[] { + // The cell grid alone decides how many columns a table has. `col` and + // `colgroup` declarations past its last column describe columns that do not + // exist — honouring them would widen the table by the sum of widths nothing + // is ever rendered into, and hand it a scroll view to hold the surplus. + const intrinsic = reduceColumnConstraints([...cells]); + if (intrinsic.length === 0) { + return []; + } + const declarations = mergeDeclarations(intrinsic, declaredWidths); + const caps = resolveCaps(declarations, contentWidth); + const columnConstraints = applyDeclaredBounds(intrinsic, declarations, caps); + const minWidths = columnConstraints.map((c) => c.minWidth); + + // 1. Below its min-content width the table cannot fit: no column may go + // under the width it needs for its longest word, so it overflows and + // `HTMLTable` scrolls it. + if (contentWidth < sum(minWidths)) { + return minWidths; + } + + // 2. Percentage columns are their own sizing class. This is the critical + // difference from resolving percentages to hard pixel minima up front: + // when the full percentage guess does not fit, browsers interpolate back + // toward the min-content guess while keeping the total at the width. + const percentages = normalizePercentages(declarations, intrinsic.length); + const percentageGuess = percentageGuessOf( + minWidths, + percentages, + caps, + contentWidth + ); + if (contentWidth <= sum(percentageGuess)) { + return interpolateWidths(minWidths, percentageGuess, contentWidth); + } + + // 3. Then move non-percentage columns toward max-content. A percentage + // column keeps the width its own sizing guess assigned. + const spreads = columnConstraints.map((c) => c.spread); + const maxContentGuess = percentageGuess.map((width, i) => + percentages[i] == null ? Math.max(width, spreads[i]!) : width + ); + if (contentWidth <= sum(maxContentGuess)) { + return interpolateWidths(percentageGuess, maxContentGuess, contentWidth); + } + + // 4. An auto-width table shrinks to its max-content size. An explicitly + // sized table — or `forceStretch` — must fill the width instead. + if (!forceStretch) { + return maxContentGuess; + } + return distributeSurplus( + maxContentGuess, + declarations, + percentages, + caps, + // The spacing each column carries, so the surplus is shared over content + // alone; `reduceColumnConstraints` reduced it the same way it reduced + // `minWidth`, which is the figure the spacing is held out of. + columnConstraints.map((c) => c.horizontalSpace), + contentWidth + ); +} diff --git a/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts b/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts index 8353d80..be9c6a3 100644 --- a/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts +++ b/packages/heuristic-table-plugin/src/helpers/createRenderTree.ts @@ -7,6 +7,7 @@ import { TableRoot } from '../shared-types'; import makeRows from './makeRows'; +import { spanInternalSpacing } from './borderSpacingGeometry'; function getRowGroupHeight(cells: TableCell[]): number { return cells.reduce((maxLen, cell) => Math.max(maxLen, cell.lenY), 0); @@ -14,11 +15,12 @@ function getRowGroupHeight(cells: TableCell[]): number { function groupCellsByVGroup(cellsByRow: TableCell[][]): TableCell[][][] { const cellsByVGroup: TableCell[][][] = []; - let rowHeight = 1; - for (let i = 0; i < cellsByRow.length; i += Math.max(rowHeight, 1)) { - const row = cellsByRow[i]; - rowHeight = getRowGroupHeight(row); + // A group is as tall as the tallest cell starting in its first row, and the + // next group starts where it ends. + for (let i = 0; i < cellsByRow.length; ) { + const rowHeight = Math.max(1, getRowGroupHeight(cellsByRow[i]!)); cellsByVGroup.push(cellsByRow.slice(i, i + rowHeight)); + i += rowHeight; } return cellsByVGroup; } @@ -99,7 +101,7 @@ function makeCell( return { ...cell, type: 'cell', - width: width + Math.max(0, cell.lenX - 1) * spacing + width: width + spanInternalSpacing(cell.lenX, spacing) }; } @@ -114,7 +116,11 @@ function makeCell( export function makeTableCells( grid: Pick, columnWidths: number[], - spacing = 0 + /** + * The table's horizontal `border-spacing`. Required: a default would let a + * caller silently drop the gaps a spanning cell absorbs from every width. + */ + spacing: number ): TableCell[] { return grid.cells.map((cell) => makeCell(columnWidths, cell, spacing)); } diff --git a/packages/heuristic-table-plugin/src/helpers/inlineStyle.ts b/packages/heuristic-table-plugin/src/helpers/inlineStyle.ts new file mode 100644 index 0000000..9bebb60 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/inlineStyle.ts @@ -0,0 +1,27 @@ +import { TNode } from '@native-html/render'; + +export function getInlineStyleValue( + tnode: TNode, + propertyName: string +): string | null { + const inlineStyle = tnode.attributes.style; + if (!inlineStyle) { + return null; + } + let value: string | null = null; + for (const declaration of inlineStyle.split(';')) { + const colonIndex = declaration.indexOf(':'); + if (colonIndex === -1) { + continue; + } + const name = declaration.slice(0, colonIndex).trim().toLowerCase(); + if (name === propertyName) { + value = declaration + .slice(colonIndex + 1) + .replace(/\s*!important\s*$/i, '') + .trim() + .toLowerCase(); + } + } + return value; +} diff --git a/packages/heuristic-table-plugin/src/helpers/measure.ts b/packages/heuristic-table-plugin/src/helpers/measure.ts index 05cbe11..31c1cd4 100644 --- a/packages/heuristic-table-plugin/src/helpers/measure.ts +++ b/packages/heuristic-table-plugin/src/helpers/measure.ts @@ -1,5 +1,6 @@ import { ViewStyle } from 'react-native'; -import { isRTL } from './tableStyles'; +import { isRTL } from './boxSides'; +import { paddingSourcesFor } from './cellPadding'; type NativeBlockRetStyle = ViewStyle; type SpacingFields = Extract< @@ -40,12 +41,14 @@ export function getHorizontalMargins(style: NativeBlockRetStyle): number { */ export function getHorizontalInsets(style: NativeBlockRetStyle): number { const rtl = isRTL(style); - const start = style.paddingInlineStart ?? style.paddingStart; - const end = style.paddingInlineEnd ?? style.paddingEnd; - const horizontal = - style.paddingInline ?? style.paddingHorizontal ?? style.padding; - const left = (rtl ? end : start) ?? style.paddingLeft ?? horizontal; - const right = (rtl ? start : end) ?? style.paddingRight ?? horizontal; + // The first declared property of the side's precedence list is the padding + // that side takes — the same list `getDefaultCellPaddingStyle` consults. + const paddingOn = (side: 'Left' | 'Right') => + paddingSourcesFor(side, rtl) + .map((property) => style[property]) + .find((value) => value != null); + const left = paddingOn('Left'); + const right = paddingOn('Right'); const borderStart = style.borderStartWidth; const borderEnd = style.borderEndWidth; return [ diff --git a/packages/heuristic-table-plugin/src/helpers/measureTable.ts b/packages/heuristic-table-plugin/src/helpers/measureTable.ts new file mode 100644 index 0000000..6103421 --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/measureTable.ts @@ -0,0 +1,129 @@ +import { ViewStyle } from 'react-native'; +import { TNode } from '@native-html/render'; +import { TableGrid } from '../shared-types'; +import type indexCellNeighbours from './indexCellNeighbours'; +import type { DeclaredColumnWidth } from './extractColumnWidths'; +import type { BorderSpacing } from './resolveBorderSpacing'; +import type { TableWidths } from './resolveTableWidths'; +import TCellConstraintsComputer from './TCellConstraintsComputer'; +import computeColumnWidths from './computeColumnWidths'; +import resolveTableStyles, { ResolvedCellStyle } from './resolveTableStyles'; +import { getHorizontalInsets } from './measure'; +import sum from './sum'; +import { spanInternalSpacing } from './borderSpacingGeometry'; + +export interface MeasureTableInput { + grid: TableGrid; + /** The table's own source style, its writing direction folded in. */ + tableStyle: ViewStyle; + borderCollapse: boolean; + /** + * Shared cell neighbours, indexed once from coordinates alone. Absent when + * borders are not collapsing, where no cell has a shared edge to resolve. + */ + neighbours: ReturnType | undefined; + /** + * Styles {@link HeuristicTablePluginConfig.getStyleForCell} returned, frozen + * from a previous pass. Empty on the first pass, when no widths exist to + * call the callback with. + */ + configStyles: ReadonlyMap; + /** + * Shared across passes on purpose: its cache of per-cell intrinsic + * constraints is what keeps a second pass from re-walking every text node. + */ + computer: TCellConstraintsComputer; + widths: TableWidths; + borderSpacing: BorderSpacing; + /** The border-spacing the table spends between and around its columns. */ + spacingWidth: number; + declaredColumnWidths: Array; +} + +export interface MeasuredTable { + cellStyles: ReadonlyMap; + tableBorderStyle: ViewStyle | null; + /** The table's own horizontal padding and border. */ + insets: number; + columnWidths: number[]; +} + +/** + * Measure every cell against the resolved styles, then size the columns. + * + * @remarks + * Pure given its input: it reads the grid's coordinates and writes each cell's + * constraints, and is safe to run twice because the second run recomputes + * every constraint it overwrites. {@link TableLayout} runs it a second time + * once `getStyleForCell` has been consulted, since a callback may change the + * padding and borders the columns are measured against. + */ +export default function measureTable({ + grid, + tableStyle, + borderCollapse, + neighbours, + configStyles, + computer, + widths, + borderSpacing, + spacingWidth, + declaredColumnWidths +}: MeasureTableInput): MeasuredTable { + const resolved = resolveTableStyles( + grid, + tableStyle, + borderCollapse, + configStyles, + neighbours + ); + const insets = getHorizontalInsets({ + ...tableStyle, + ...resolved.tableBorderStyle + }); + // The width left for the columns, once the table's own padding, border and + // border-spacing are taken out of the width it may occupy. + const assignableWidth = Math.max( + 0, + widths.usedTableWidth - insets - spacingWidth + ); + for (const cell of grid.cells) { + const constraints = computer.computeCellConstraints( + cell.tnode, + resolved.cellStyles.get(cell.tnode)!.style, + assignableWidth + ); + // A spanning cell also occupies the gaps between its columns. + const internalSpacing = spanInternalSpacing( + cell.lenX, + borderSpacing.horizontal + ); + cell.constraints = { + ...constraints, + minWidth: Math.max(0, constraints.minWidth - internalSpacing), + maxWidth: Math.max(0, constraints.maxWidth - internalSpacing) + }; + } + let columnWidths = computeColumnWidths( + { cells: grid.cells, assignableWidth, forceStretch: widths.forceStretch }, + declaredColumnWidths + ); + // A `min-width` on the table floors the columns too, so a table told to be + // at least this wide does not leave the surplus to its padding. + const minLayoutWidth = Math.max( + 0, + (widths.minWidth ?? 0) - insets - spacingWidth + ); + if (sum(columnWidths) < minLayoutWidth) { + const raised = computeColumnWidths( + { + cells: grid.cells, + assignableWidth: minLayoutWidth, + forceStretch: true + }, + declaredColumnWidths + ); + if (sum(raised) > sum(columnWidths)) columnWidths = raised; + } + return { ...resolved, insets, columnWidths }; +} diff --git a/packages/heuristic-table-plugin/src/helpers/relaxHeightConstraint.ts b/packages/heuristic-table-plugin/src/helpers/relaxHeightConstraint.ts index ecc3411..b37e721 100644 --- a/packages/heuristic-table-plugin/src/helpers/relaxHeightConstraint.ts +++ b/packages/heuristic-table-plugin/src/helpers/relaxHeightConstraint.ts @@ -17,6 +17,11 @@ type HeightConstraints = Pick; * @param style - Native styles of a `table`, `tr`, `th` or `td` element. * * @returns The same styles, with `height` removed and merged into `minHeight`. + * + * When the two are declared in different units — a numeric `height` beside a + * percentage `minHeight`, say — they cannot be compared, so the declared + * `minHeight` is kept and the `height` is dropped rather than guessing which + * resolves larger. */ export default function relaxHeightConstraint( style: T diff --git a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts index 02c05f2..758c860 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveAvailableWidth.ts @@ -1,7 +1,7 @@ import { TNode } from '@native-html/render'; import { getHorizontalInsets, getHorizontalMargins } from './measure'; import { clampWidth, resolveWidthConstraints } from './resolveWidth'; -import { getPaintedBlockStyle } from './tableStyles'; +import { getPaintedBlockStyle } from './cellPadding'; import type { CellContentBox } from '../CellContentWidthContext'; /** @@ -13,9 +13,13 @@ function reduceToContentBox(tnode: TNode, containingWidth: number): number { // user-agent padding it is about to spend, and a table nested in it would // overflow by that much once per level of nesting. const style = getPaintedBlockStyle(tnode); + // Measured against the same style the insets come from, rather than the + // declared one: identical today, since the painted style only adds padding, + // but stating it keeps the two from drifting apart. const { width, minWidth, maxWidth } = resolveWidthConstraints( tnode, - containingWidth + containingWidth, + { style } ); // A declared width is a border box in React Native, so it already accounts // for padding and border; an auto width fills the containing block, minus diff --git a/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts b/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts index a89aa07..f7b4377 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveBorderSpacing.ts @@ -1,5 +1,5 @@ import { TNode } from '@native-html/render'; -import { getInlineStyleValue } from './tableStyles'; +import { getInlineStyleValue } from './inlineStyle'; import { resolveAttributeLength } from './resolveWidth'; export interface BorderSpacing { diff --git a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts index 994e81b..de0f581 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts @@ -1,13 +1,9 @@ import { TNode } from '@native-html/render'; import { ViewStyle } from 'react-native'; import { TableGrid } from '../shared-types'; -import { - getCollapsedCellBorderStyle, - getCollapsedTableBorderStyle, - resolveConfiguredCellStyle, - getSourceBlockStyle -} from './tableStyles'; -import composeCellStyle from './composeCellStyle'; +import { getSourceBlockStyle, resolveConfiguredCellStyle } from './cellPadding'; +import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle } from './collapseBorders'; +import composeCellStyle from './cellPadding'; import indexCellNeighbours from './indexCellNeighbours'; /** One saved style resolution shared by measurement and rendering. */ diff --git a/packages/heuristic-table-plugin/src/helpers/resolveTableWidths.ts b/packages/heuristic-table-plugin/src/helpers/resolveTableWidths.ts new file mode 100644 index 0000000..6b0539c --- /dev/null +++ b/packages/heuristic-table-plugin/src/helpers/resolveTableWidths.ts @@ -0,0 +1,90 @@ +import { ViewStyle } from 'react-native'; +import { TNode } from '@native-html/render'; +import type { CellContentBox } from '../CellContentWidthContext'; +import { Settings } from '../shared-types'; +import resolveAvailableWidth from './resolveAvailableWidth'; +import { clampWidth, resolveWidthConstraints } from './resolveWidth'; +import { getHorizontalMargins } from './measure'; + +/** + * Tables fill the width their containing block leaves them unless the config + * opts out, so that a table reads as part of the surrounding document rather + * than as a shrink-wrapped island. + */ +const DEFAULT_FORCE_STRETCH = true; + +/** The width envelope a table is laid out inside, before its grid is known. */ +export interface TableWidths { + /** + * The border-box width the table may occupy, after the horizontal spacing of + * every ancestor and the table's own margins have been subtracted from + * {@link Settings.contentWidth}. + */ + availableWidth: number; + /** + * The border-box width the table would take if nothing constrained it + * further: its declared width when it has one, else the width on offer, + * bounded either way by `min-width` and `max-width`. + */ + usedTableWidth: number; + /** The declared `min-width`, which also floors the columns. */ + minWidth: number | null; + /** Whether the columns must fill the table width rather than shrink to fit. */ + forceStretch: boolean; +} + +/** + * Resolve how wide a table may be, independently of what is inside it. + * + * @remarks + * Kept apart from measuring the grid because it needs none of it: this is the + * envelope the ancestors and the table's own CSS allow, and the cells are + * later fitted into whatever is left of it. + */ +export default function resolveTableWidths( + tnode: TNode, + style: ViewStyle, + config: Settings, + cellContentBox?: CellContentBox +): TableWidths { + const containingWidth = resolveAvailableWidth( + tnode, + config.contentWidth, + cellContentBox + ); + const availableWidth = Math.max( + 0, + containingWidth - getHorizontalMargins(style) + ); + // Percentages resolve against the width the table may actually occupy, + // margins already deducted, rather than against the whole containing block. + // Resolving `width:100%` against the latter would hand the columns more + // width than the table box is allowed — by exactly the margins — and the + // surplus would then be shown through a horizontal scroller the same table + // without a declared width never gets. An absolute width is untouched by + // this and still overflows into that scroller when it does not fit. + const { width, minWidth, maxWidth } = resolveWidthConstraints( + tnode, + availableWidth + ); + const declaredTableWidth = + width === null ? null : clampWidth(width, minWidth, maxWidth); + return { + availableWidth, + // `min-width` and `max-width` bound the table width whether it is declared + // or filled. A table that merely asks for *at least* 200px still fills the + // width it was offered; one capped at 300px stops there rather than + // stretching past its own ceiling. + usedTableWidth: clampWidth( + declaredTableWidth ?? availableWidth, + minWidth, + maxWidth + ), + minWidth, + // A table with a specified width distributes that width over its columns; + // shrink-to-fit only applies when the table width is auto, and is opt-in. + forceStretch: + (config.forceStretch ?? DEFAULT_FORCE_STRETCH) || + declaredTableWidth !== null + }; +} diff --git a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts b/packages/heuristic-table-plugin/src/helpers/tableStyles.ts deleted file mode 100644 index facdf0a..0000000 --- a/packages/heuristic-table-plugin/src/helpers/tableStyles.ts +++ /dev/null @@ -1,631 +0,0 @@ -import { I18nManager, ViewStyle } from 'react-native'; -import { TNode } from '@native-html/render'; -import { DisplayCell, TableCell, TableGrid } from '../shared-types'; -import type { CellNeighbours } from './indexCellNeighbours'; - -export type BorderCollapse = 'collapse' | 'separate'; - -export type CellVerticalAlign = 'baseline' | 'bottom' | 'middle' | 'top'; - -export function getInlineStyleValue( - tnode: TNode, - propertyName: string -): string | null { - const inlineStyle = tnode.attributes.style; - if (!inlineStyle) { - return null; - } - let value: string | null = null; - for (const declaration of inlineStyle.split(';')) { - const colonIndex = declaration.indexOf(':'); - if (colonIndex === -1) { - continue; - } - const name = declaration.slice(0, colonIndex).trim().toLowerCase(); - if (name === propertyName) { - value = declaration - .slice(colonIndex + 1) - .replace(/\s*!important\s*$/i, '') - .trim() - .toLowerCase(); - } - } - return value; -} - -function normalizeVerticalAlign(value: string): CellVerticalAlign | null { - switch (value.toLowerCase()) { - case 'top': - case 'middle': - case 'bottom': - case 'baseline': - return value.toLowerCase() as CellVerticalAlign; - case 'initial': - case 'unset': - return 'baseline'; - case 'inherit': - case 'revert': - case 'revert-layer': - return null; - default: - // Lengths, percentages and the inline-only vertical-align keywords are - // treated as baseline for table cells by CSS. - return 'baseline'; - } -} - -/** - * The alignment HTML's user-agent stylesheet gives a table cell. - * - * @remarks - * Row groups and direct table rows are aligned to the middle, and rows and - * cells inherit it. Being a user-agent declaration, it is outranked by any - * author style that resolves to the same native property. - * - * @public - */ -export const DEFAULT_CELL_VERTICAL_ALIGN: CellVerticalAlign = 'middle'; - -/** - * The padding HTML's user-agent stylesheet gives a table cell. - * - * @remarks - * `td, th { padding: 1px }`, per the - * {@link https://html.spec.whatwg.org/multipage/rendering.html#tables-2 | HTML rendering rules}. - * Being a user-agent declaration, it is outranked by any author padding, side - * by side: a cell which declares `padding-left` alone still gets the default - * on the three sides it left untouched. - * - * @public - */ -export const DEFAULT_CELL_PADDING = 1; - -/** The four physical edges of a box, spelled as React Native style suffixes. */ -export type BoxSide = 'Bottom' | 'Left' | 'Right' | 'Top'; - -export const BOX_SIDES = ['Top', 'Right', 'Bottom', 'Left'] as const; - -/** - * Whether a style resolves its logical edges right-to-left. - * - * @remarks - * An explicit `direction` wins; otherwise the app-wide setting decides, which - * is what Yoga itself does with an unset direction. - */ -export function isRTL(style: ViewStyle): boolean { - return ( - style.direction === 'rtl' || - (style.direction !== 'ltr' && I18nManager.isRTL) - ); -} - -/** The logical edge a physical horizontal side maps to, or `null` vertically. */ -function logicalSideOf(side: BoxSide, rtl: boolean): 'End' | 'Start' | null { - if (side === 'Left') return rtl ? 'End' : 'Start'; - if (side === 'Right') return rtl ? 'Start' : 'End'; - return null; -} - -type PaddingSide = BoxSide; - -/** - * Every style property which declares padding on a given side. - * - * @remarks - * Source CSS always reaches the plugin expanded per side, but - * {@link HeuristicTablePluginConfig.getStyleForCell} is hand-written React - * Native style, where any shorthand is fair game. A shorthand cannot simply be - * overwritten either: Yoga resolves a side against its own edge and only falls - * back to the `padding` edge, so a longhand default would beat an author - * `padding` whatever the merge order. Each shorthand is therefore read as a - * declaration of every side it covers. - * - * The writing-direction keywords count on both horizontal sides. Which of the - * two they land on is not known here, and reserving both is the harmless - * choice: it withholds a default rather than fighting the author declaration. - */ -const paddingSideKeys: Record = { - Top: [ - 'paddingTop', - 'paddingBlockStart', - 'paddingBlock', - 'paddingVertical', - 'padding' - ], - Right: [ - 'paddingRight', - 'paddingEnd', - 'paddingStart', - 'paddingInlineEnd', - 'paddingInlineStart', - 'paddingInline', - 'paddingHorizontal', - 'padding' - ], - Bottom: [ - 'paddingBottom', - 'paddingBlockEnd', - 'paddingBlock', - 'paddingVertical', - 'padding' - ], - Left: [ - 'paddingLeft', - 'paddingStart', - 'paddingEnd', - 'paddingInlineStart', - 'paddingInlineEnd', - 'paddingInline', - 'paddingHorizontal', - 'padding' - ] -}; - -/** - * The source block style of a node, with its writing direction folded in. - * - * @remarks - * `direction` is a flow property, not a retained box one: the CSS processor - * files it under `nativeBlockFlow` (`makePropertiesValidators`, the sole - * member of the block-flow model), and unlike `nativeBlockRet` that bag is - * inherited — a cell of a `
` carries `rtl` - * without declaring it. - * - * Every pass which resolves a *logical* edge has to see it: {@link isRTL} - * here, and `getHorizontalInsets` in `measure`. Reading `nativeBlockRet` alone - * makes an authored `direction` invisible, so an RTL table resolves its - * logical borders and padding onto the wrong physical side. - */ -export function getSourceBlockStyle(tnode: TNode): ViewStyle { - const style = tnode.styles.nativeBlockRet; - const direction = tnode.styles.nativeBlockFlow?.direction; - return direction == null ? style : { ...style, direction }; -} - -/** - * Whether a node is a table cell, and so subject to the cell rules of the - * user-agent stylesheet. - */ -export function isTableCell(tnode: TNode): boolean { - return tnode.tagName === 'td' || tnode.tagName === 'th'; -} - -/** - * Everything a node is painted with, the user-agent cell rules included. - * - * @remarks - * `nativeBlockRet` holds source CSS alone, so a cell which declares no padding - * appears to have none while the renderer gives it - * {@link DEFAULT_CELL_PADDING}. Any pass which measures a box against what - * ends up on screen has to reconcile the two here first. - */ -export function getPaintedBlockStyle( - tnode: TNode -): TNode['styles']['nativeBlockRet'] { - const style = getSourceBlockStyle(tnode); - if (!isTableCell(tnode)) { - return style; - } - return { ...getDefaultCellPaddingStyle(style), ...style }; -} - -/** - * The padding a table cell owes to {@link DEFAULT_CELL_PADDING} alone. - * - * @param declaredStyles - Everything the cell declares padding in, source CSS - * and {@link HeuristicTablePluginConfig.getStyleForCell} alike. A side any of - * them covers is left out of the result. - * - * @remarks - * The result is expanded per side rather than left as a `padding` shorthand, - * so that the sides an author did declare stay untouched. - */ -export function getDefaultCellPaddingStyle( - ...declaredStyles: (ViewStyle | null | undefined)[] -): ViewStyle { - const resolvedStyle: ViewStyle = {}; - for (const side of BOX_SIDES) { - const isDeclared = declaredStyles.some((style) => - style - ? paddingSideKeys[side].some((property) => style[property] != null) - : false - ); - if (!isDeclared) { - Object.assign(resolvedStyle, { - [`padding${side}`]: DEFAULT_CELL_PADDING - }); - } - } - return resolvedStyle; -} - -/** - * Expand callback shorthands so resolved source longhands cannot mask them. - * - * @remarks - * Every shorthand Yoga resolves *after* a per-side edge has to be expanded - * here, the logical `paddingInline` / `paddingBlock` pair included: a cell - * declaring `padding-left` in its source CSS reaches the merge as a longhand, - * which would otherwise win on that one side and leave the callback's - * shorthand painting the other three — the opposite of the documented rule - * that callback padding replaces source padding outright. - * - * The per-side logical properties (`paddingStart`, `paddingInlineEnd` and - * friends) need no expansion: Yoga already resolves them ahead of the physical - * longhands, so they mask the source rather than being masked by it. - */ -export function resolveConfiguredCellStyle( - style: ViewStyle | null | undefined -): ViewStyle | null { - if (!style) return null; - const horizontal = - style.paddingInline ?? style.paddingHorizontal ?? style.padding; - const vertical = style.paddingBlock ?? style.paddingVertical ?? style.padding; - return { - ...(horizontal != null - ? { paddingLeft: horizontal, paddingRight: horizontal } - : null), - ...(vertical != null - ? { paddingTop: vertical, paddingBottom: vertical } - : null), - ...style - }; -} - -/** - * Resolve the vertical alignment a native table cell should emulate. - * - * The CSS processor intentionally drops `vertical-align` because React Native - * cannot consume it directly, so table renderers recover the value from inline - * CSS and the legacy `valign` attribute here. - * - * @returns The declared alignment, or `null` when the cell inherits nothing - * but {@link DEFAULT_CELL_VERTICAL_ALIGN}. Callers need the distinction: the - * default may not overwrite an author `justify-content`, whereas a declared - * alignment must. - */ -export function resolveCellVerticalAlign( - tnode: TNode -): CellVerticalAlign | null { - for ( - let current: TNode | null = tnode; - current && current.tagName !== 'table'; - current = current.parent - ) { - const inlineValue = getInlineStyleValue(current, 'vertical-align'); - if (inlineValue) { - const normalized = normalizeVerticalAlign(inlineValue); - if (normalized) { - return normalized; - } - } - const attributeValue = current.attributes.valign; - if (attributeValue) { - const normalized = normalizeVerticalAlign(attributeValue); - if (normalized) { - return normalized; - } - } - } - return null; -} - -/** - * Resolve whether a table uses the collapsing border model. - * - * Inline `border-collapse` is not part of React Native styles, so it must be - * read from the source DOM. The `rules` attribute also implies collapsed - * borders in the HTML rendering rules. - */ -export function resolveBorderCollapse( - tnode: TNode, - configuredValue?: BorderCollapse -): boolean { - if (configuredValue) { - return configuredValue === 'collapse'; - } - const ownValue = getInlineStyleValue(tnode, 'border-collapse'); - if (ownValue === 'collapse' || ownValue === 'separate') { - return ownValue === 'collapse'; - } - if (tnode.attributes.rules) { - return true; - } - // border-collapse is inherited. Only inline declarations are available to - // the plugin after unsupported web-only properties have been processed. - for (let parent = tnode.parent; parent; parent = parent.parent) { - const inheritedValue = getInlineStyleValue(parent, 'border-collapse'); - if (inheritedValue === 'collapse' || inheritedValue === 'separate') { - return inheritedValue === 'collapse'; - } - } - return false; -} - -type BorderSide = BoxSide; - -interface BorderCandidate { - color: ViewStyle['borderColor']; - fromCell: boolean; - style: NonNullable; - width: number; -} - -const borderStylePriority: Record = { - dotted: 0, - dashed: 1, - solid: 2 -}; - -function borderCandidate( - style: ViewStyle, - side: BorderSide, - fromCell: boolean -): BorderCandidate { - const logicalSide = logicalSideOf(side, isRTL(style)); - // The CSS processor always expands `border` per side, but - // `getStyleForCell` is hand-written and the shorthand is the natural way to - // reach for a border there, so fall back to it. An explicit per-side `0` - // still wins, as it does in React Native. - const width = ((logicalSide - ? style[`border${logicalSide}Width`] - : undefined) ?? - style[`border${side}Width`] ?? - style.borderWidth) as number | undefined; - const color = ((logicalSide - ? style[`border${logicalSide}Color`] - : undefined) ?? - style[`border${side}Color`] ?? - style.borderColor) as ViewStyle['borderColor']; - return { - color: color ?? 'black', - fromCell, - style: style.borderStyle ?? 'solid', - width: typeof width === 'number' ? width : 0 - }; -} - -/** Prevent logical edges from overriding the resolved physical borders. */ -function clearLogicalBorders(style: ViewStyle): ViewStyle { - const cleared: ViewStyle = {}; - for (const key of [ - 'borderStartWidth', - 'borderEndWidth', - 'borderStartColor', - 'borderEndColor' - ] as const) { - if (style[key] != null) Object.assign(cleared, { [key]: undefined }); - } - return cleared; -} - -function resolveBorderConflict( - winner: BorderCandidate, - candidate: BorderCandidate -): BorderCandidate { - if (candidate.width !== winner.width) { - return candidate.width > winner.width ? candidate : winner; - } - const candidatePriority = borderStylePriority[candidate.style]; - const winnerPriority = borderStylePriority[winner.style]; - if (candidatePriority !== winnerPriority) { - return candidatePriority > winnerPriority ? candidate : winner; - } - // With otherwise equal borders, CSS gives a cell precedence over the table. - return candidate.fromCell && !winner.fromCell ? candidate : winner; -} - -/** - * A cell as the collapsing border model sees it: where it sits in the matrix, - * and the node its source styles come from. - */ -type CollapsibleCell = Pick; - -/** - * The matrix a collapsed border is resolved over. - * - * @remarks - * `maxX` and `maxY` come from the display rather than from the cells, so that - * this agrees with {@link getCollapsedCellBorderStyle} on which cells are at - * an edge. The two disagree for a `rowspan` that overruns the last row: the - * table does not grow rows to fit it, so the cell is clipped and the last row - * the display laid out stays the bottom edge. - */ -type CollapsibleMatrix = { - cells: readonly C[]; -} & Pick; - -/** - * Whether a cell sits against one of the table's own edges. - * - * @remarks - * Shared by both collapsing passes on purpose. The wrapper resolves an outer - * border from the cells at an edge, and each cell then decides whether that - * same edge is its own; the two must agree, or a boundary is painted twice or - * not at all. - * - * A span that overruns the matrix is clipped to it rather than growing the - * table, so it sits at the edge it overran — hence `>=` rather than `===`. - */ -function isAtOuterEdge( - cell: Pick, - side: BorderSide, - { maxX, maxY }: Pick -): boolean { - switch (side) { - case 'Top': - return cell.y === 0; - case 'Right': - return cell.x + cell.lenX - 1 >= maxX; - case 'Bottom': - return cell.y + cell.lenY - 1 >= maxY; - case 'Left': - return cell.x === 0; - } -} - -function cellsAtOuterEdge( - { cells, maxX, maxY }: CollapsibleMatrix, - side: BorderSide -): readonly C[] { - return cells.filter((cell) => isAtOuterEdge(cell, side, { maxX, maxY })); -} - -function sourceCellStyle(cell: CollapsibleCell): ViewStyle { - return getSourceBlockStyle(cell.tnode); -} - -/** - * Resolve each outer collapsed border between the table and its edge cells. - * - * React Native cannot render different border segments along one side of a - * View, so the strongest cell candidate is used for that complete side. This - * still preserves the central CSS conflict rules: wider borders win, then - * stronger styles, then cells over the table. - * - * @param matrix - See {@link CollapsibleMatrix}. - * @param tableStyle - The table source style. Each pass starts from this - * rather than a previously collapsed result, so a callback can remove a - * source cell border as well as strengthen it. - * @param getCellStyle - Everything an edge cell paints with. Defaults to its - * source CSS alone. - */ -export function getCollapsedTableBorderStyle( - matrix: CollapsibleMatrix, - tableStyle: ViewStyle, - getCellStyle: (cell: C) => ViewStyle = sourceCellStyle -): ViewStyle { - const resolvedStyle: ViewStyle = clearLogicalBorders(tableStyle); - let strongestStyle: BorderCandidate['style'] | null = null; - for (const side of BOX_SIDES) { - const winner = cellsAtOuterEdge(matrix, side).reduce( - (currentWinner, cell) => - resolveBorderConflict( - currentWinner, - borderCandidate(getCellStyle(cell), side, true) - ), - borderCandidate(tableStyle, side, false) - ); - Object.assign(resolvedStyle, { - [`border${side}Width`]: winner.width, - [`border${side}Color`]: winner.color - }); - if ( - winner.width > 0 && - (strongestStyle === null || - borderStylePriority[winner.style] > borderStylePriority[strongestStyle]) - ) { - strongestStyle = winner.style; - } - } - resolvedStyle.borderStyle = strongestStyle ?? 'solid'; - return resolvedStyle; -} - -/** - * Which boundaries of the table a cell sits against. - * - * @remarks - * `tableBorderStyle` is the wrapper edge {@link getCollapsedTableBorderStyle} - * resolved, and is consulted rather than assumed: a side the wrapper leaves - * bare has to stay with the cell. - */ -export interface CollapsedCellEdges { - maxX: number; - maxY: number; - tableBorderStyle: ViewStyle | null; - /** - * The cells sharing this cell's trailing and bottom boundary, from - * {@link indexCellNeighbours}. - * - * @remarks - * Absent when the caller has no matrix to index — a `td` renderer reached - * outside this plugin's table — in which case the cell keeps its own - * borders rather than resolving them against neighbours it cannot see. - */ - neighbours?: CellNeighbours; - getCellStyle?: (cell: CollapsibleCell) => ViewStyle; -} - -/** - * Draw every shared cell boundary exactly once. - * - * @param cell - The cell's position in the table matrix. - * @param cellStyle - Everything the cell paints with, source CSS and - * {@link HeuristicTablePluginConfig.getStyleForCell} alike. - * @param edges - See {@link CollapsedCellEdges}. - * - * @remarks - * Each cell owns its trailing and bottom boundary, and the table wrapper owns - * the four outer ones it resolved against the edge cells. This mirrors the - * visible result of the collapsing model for the border styles React Native - * can render, without changing the flex geometry used for row and col spans. - * - * Shared boundaries compare the actual adjacent cells. Where spans bring - * several neighbours against one side, the strongest candidate paints that - * whole side; a native View cannot paint differently styled border segments. - */ -export function getCollapsedCellBorderStyle( - cell: Pick, - cellStyle: ViewStyle, - { - maxX, - maxY, - tableBorderStyle, - neighbours, - getCellStyle = sourceCellStyle - }: CollapsedCellEdges -): ViewStyle { - const resolvedStyle: ViewStyle = clearLogicalBorders(cellStyle); - const isOuterEdge = (side: BorderSide) => - isAtOuterEdge(cell, side, { maxX, maxY }); - const isPaintedByTable = (side: BorderSide) => { - const width = tableBorderStyle?.[`border${side}Width`]; - return typeof width === 'number' && width > 0; - }; - let strongestStyle: BorderCandidate['style'] | null = null; - const paint = (side: BorderSide, candidate: BorderCandidate | null) => { - if (!candidate || candidate.width === 0) { - Object.assign(resolvedStyle, { [`border${side}Width`]: 0 }); - return; - } - if ( - strongestStyle === null || - borderStylePriority[candidate.style] > borderStylePriority[strongestStyle] - ) { - strongestStyle = candidate.style; - } - Object.assign(resolvedStyle, { - [`border${side}Width`]: candidate.width, - [`border${side}Color`]: candidate.color - }); - }; - const ownBorder = (side: BorderSide) => - borderCandidate(cellStyle, side, true); - const keepOuterBorder = (side: BorderSide) => - isPaintedByTable(side) ? null : ownBorder(side); - // A leading boundary is always drawn by the neighbour that precedes it, - // except on the outside where there is no neighbour to draw it. - paint('Top', isOuterEdge('Top') ? keepOuterBorder('Top') : null); - paint('Left', isOuterEdge('Left') ? keepOuterBorder('Left') : null); - for (const [side, opposite] of [ - ['Right', 'Left'], - ['Bottom', 'Top'] - ] as const) { - paint( - side, - isOuterEdge(side) - ? keepOuterBorder(side) - : (neighbours?.[side] ?? []).reduce( - (winner, neighbour) => - resolveBorderConflict( - winner, - borderCandidate(getCellStyle(neighbour), opposite, true) - ), - ownBorder(side) - ) - ); - } - if (strongestStyle !== null) resolvedStyle.borderStyle = strongestStyle; - return resolvedStyle; -} diff --git a/packages/heuristic-table-plugin/src/shared-types.ts b/packages/heuristic-table-plugin/src/shared-types.ts index 0a334d9..e90c1fc 100644 --- a/packages/heuristic-table-plugin/src/shared-types.ts +++ b/packages/heuristic-table-plugin/src/shared-types.ts @@ -5,7 +5,7 @@ import { TBlock, TNode } from '@native-html/render'; -import TableLayout from './TableLayout'; +import type TableLayout from './TableLayout'; import type { ResolvedCellStyle } from './helpers/resolveTableStyles'; import type { FontWeightCoefficients } from './helpers/TCellConstraintsComputer'; @@ -312,7 +312,6 @@ export interface HeuristicTablePluginConfig { export interface HTMLTableProps extends CustomRendererProps { layout: TableLayout; config: HeuristicTablePluginConfig; - settings: Settings; } /** @@ -337,10 +336,30 @@ export interface TableCellPropsFromParent extends PropsFromParent { * here is optional or has a defined absent state. */ export interface InternalTableCellPropsFromParent - extends TableCellPropsFromParent { + extends TableCellPropsFromParent, + TableGeometry { resolvedCellStyle?: ResolvedCellStyle; +} + +/** + * What every cell of one table shares: where the matrix ends, and how its + * borders were collapsed. + * + * @remarks + * Composed by both the render context and the props a cell receives, so the + * two cannot state it differently. + */ +export interface TableGeometry { borderCollapse: boolean; maxX: number; maxY: number; + /** + * The wrapper edge the collapsing model resolved. + * + * @remarks + * Cells need this, not just their position in the matrix: an outer boundary + * the wrapper leaves bare is still theirs to paint. + */ tableBorderStyle: ViewStyle | null; + config?: HeuristicTablePluginConfig; } diff --git a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts index 126ef08..c48182f 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableCellProps.ts @@ -6,15 +6,10 @@ import { } from '@native-html/render'; import { InternalTableCellPropsFromParent } from './shared-types'; import relaxHeightConstraint from './helpers/relaxHeightConstraint'; -import composeCellStyle from './helpers/composeCellStyle'; -import { - CellVerticalAlign, - getCollapsedCellBorderStyle, - resolveConfiguredCellStyle, - resolveCellVerticalAlign, - getSourceBlockStyle, - DEFAULT_CELL_VERTICAL_ALIGN -} from './helpers/tableStyles'; +import composeCellStyle from './helpers/cellPadding'; +import { getSourceBlockStyle, resolveConfiguredCellStyle } from './helpers/cellPadding'; +import { CellVerticalAlign, DEFAULT_CELL_VERTICAL_ALIGN, resolveCellVerticalAlign } from './helpers/cellVerticalAlign'; +import { getCollapsedCellBorderStyle } from './helpers/collapseBorders'; /** * How a table cell emulates `vertical-align` in a column flex container. @@ -78,7 +73,7 @@ export default function useHtmlTableCellProps({ } = propsFromParent; const styleFromConfig = resolvedCellStyle ? resolvedCellStyle.configStyle - : resolveConfiguredCellStyle(config?.getStyleForCell?.call(null, cell)); + : resolveConfiguredCellStyle(config?.getStyleForCell?.(cell)); const verticalAlign = resolveCellVerticalAlign(props.tnode); // Vertical table-cell alignment and horizontal colspan centering are // independent, so keep both declarations in the same style contribution. diff --git a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts index 86a1820..820c772 100644 --- a/packages/heuristic-table-plugin/src/useHtmlTableProps.ts +++ b/packages/heuristic-table-plugin/src/useHtmlTableProps.ts @@ -50,7 +50,12 @@ export default function useHtmlTableProps( { sharedProps, tnode, ...props }: CustomRendererProps, options: { /** - * If present, overrides contentWidth from shared props. + * Lay the table out against this width instead of the document's. + * + * @remarks + * Also detaches the table from the cell it sits in, if any: an explicit + * width is taken as the whole story, so the content box of an enclosing + * cell is not subtracted from it as well. */ overrideContentWidth?: number; } = {} @@ -69,10 +74,11 @@ export default function useHtmlTableProps( const getStyleForCell = table?.getStyleForCell; const sharedContentWidth = useContentWidth(); const cellContentBox = useContext(CellContentWidthContext); - const contentWidth = + const override = typeof options.overrideContentWidth === 'number' ? options.overrideContentWidth - : sharedContentWidth; + : undefined; + const contentWidth = override ?? sharedContentWidth; const settings = useMemo( () => ({ contentWidth, @@ -94,14 +100,10 @@ export default function useHtmlTableProps( const layout = useTableLayout({ tnode, settings, - cellContentBox: - typeof options.overrideContentWidth === 'number' - ? undefined - : cellContentBox + cellContentBox: override === undefined ? cellContentBox : undefined }); return { layout, - settings, config: table ?? EMPTY_CONFIG, sharedProps, tnode, diff --git a/packages/plugins-core/package.json b/packages/plugins-core/package.json index c56a706..7bc5794 100644 --- a/packages/plugins-core/package.json +++ b/packages/plugins-core/package.json @@ -15,7 +15,7 @@ "scripts": { "test": "yarn test:ts && yarn test:lint && yarn test:jest", "test:jest": "jest src/", - "test:ts": "tsc --noEmit", + "test:ts": "tsc --noEmit && tsc -p src/__tests__/tsconfig.json", "test:lint": "eslint src/", "build": "yarn build:source && yarn build:defs", "build:source": "bob build", diff --git a/packages/plugins-core/src/__tests__/linkPressTargetToOnDOMLinkPressArgs.test.ts b/packages/plugins-core/src/__tests__/linkPressTargetToOnDOMLinkPressArgs.test.ts index a1b3928..5c33d26 100644 --- a/packages/plugins-core/src/__tests__/linkPressTargetToOnDOMLinkPressArgs.test.ts +++ b/packages/plugins-core/src/__tests__/linkPressTargetToOnDOMLinkPressArgs.test.ts @@ -2,9 +2,7 @@ import linkPressTargetToOnDOMLinkPressArgs from '../linkPressTargetToOnDOMLinkPr describe('linkPressTargetToOnDOMLinkPressArgs', () => { it('should transform all attributes', () => { - const expectedOutput: ReturnType< - typeof linkPressTargetToOnDOMLinkPressArgs - > = [ + const expectedOutput = [ { nativeEvent: {} } as any, 'https://google.com/', { diff --git a/packages/plugins-core/src/__tests__/tsconfig.json b/packages/plugins-core/src/__tests__/tsconfig.json index c8a11dd..047cb48 100644 --- a/packages/plugins-core/src/__tests__/tsconfig.json +++ b/packages/plugins-core/src/__tests__/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../../../tsconfig-base.json", "compilerOptions": { "types": ["jest"], - "noEmit": true, - "ignoreDeprecations": "6.0" + "noEmit": true }, "exclude": ["../../node_modules", "../../lib"] } From 6e6257b5f9a97e855556457b7216f6a06eb3afe5 Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 22:10:33 +0200 Subject: [PATCH 20/21] fix(heuristic-table-plugin): respect table direction when collapsing borders --- .../src/__tests__/tableRendering.test.tsx | 26 +++++ .../src/__tests__/writingDirection.test.ts | 94 +++++++++++++++++++ .../src/helpers/__tests__/cellStyles.test.ts | 2 +- .../__tests__/indexCellNeighbours.test.ts | 2 +- .../src/helpers/collapseBorders.ts | 33 ++++--- .../src/helpers/indexCellNeighbours.ts | 9 +- .../src/helpers/resolveTableStyles.ts | 2 + 7 files changed, 149 insertions(+), 19 deletions(-) diff --git a/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx b/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx index 03354e6..5e8dc5b 100644 --- a/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx +++ b/packages/heuristic-table-plugin/src/__tests__/tableRendering.test.tsx @@ -277,3 +277,29 @@ describe('row height constraints', () => { expect(StyleSheet.flatten(rows[0].props.style).height).toBeUndefined(); }); }); + +it('renders an RTL divider on the shared edge instead of the table frame', () => { + const rendered = render( + ' + + '' + + '
AB
' + }} + renderers={renderers} + /> + ); + expect(rendered.getByTestId('table')).toHaveStyle({ + direction: 'rtl', + borderLeftWidth: 0, + borderRightWidth: 0 + }); + const cells = rendered.getAllByTestId('td'); + expect(cells[0]).toHaveStyle({ + borderLeftWidth: 8, + borderLeftColor: 'red', + borderRightWidth: 0 + }); + expect(cells[1]).toHaveStyle({ borderLeftWidth: 0, borderRightWidth: 0 }); +}); diff --git a/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts b/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts index f6724b9..ace65c5 100644 --- a/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts +++ b/packages/heuristic-table-plugin/src/__tests__/writingDirection.test.ts @@ -48,3 +48,97 @@ describe('authored writing direction', () => { } ); }); + +describe('collapsed borders in multi-column tables', () => { + it.each(['ltr', 'rtl'] as const)( + 'keeps the internal divider inside a %s table', + (direction) => { + const end = direction === 'rtl' ? 'Left' : 'Right'; + const start = direction === 'rtl' ? 'Right' : 'Left'; + const layout = new TableLayout( + createTableTNode( + `` + + `` + + `` + + '
AB
' + ), + { contentWidth: 300 } + ); + expect(layout.tableBorderStyle).toMatchObject({ + borderLeftWidth: 0, + borderRightWidth: 0 + }); + expect(layout.cellStyles.get(layout.cells[0]!.tnode)!.borderStyle).toMatchObject({ + [`border${end}Width`]: 8, + [`border${end}Color`]: 'red', + [`border${start}Width`]: 0 + }); + expect(layout.cellStyles.get(layout.cells[1]!.tnode)!.borderStyle).toMatchObject({ + borderLeftWidth: 0, + borderRightWidth: 0 + }); + } + ); + + it('uses table direction for geometry and cell direction for logical borders', () => { + const layout = new TableLayout( + createTableTNode( + '' + + '
AB
' + ), + { + contentWidth: 300, + getStyleForCell: (cell) => ({ + borderStartWidth: cell.x === 0 ? 8 : 4, + borderStartColor: 'red' + }) + } + ); + // Both logical starts face the shared boundary despite opposite text directions. + expect(layout.tableBorderStyle).toMatchObject({ + borderLeftWidth: 0, + borderRightWidth: 0 + }); + expect(layout.cellStyles.get(layout.cells[0]!.tnode)!.borderStyle).toMatchObject({ + borderLeftWidth: 8, + borderRightWidth: 0, + borderStartWidth: undefined + }); + }); + + it('resolves RTL outer edges and every neighbour of a spanning cell', () => { + const layout = new TableLayout( + createTableTNode( + '' + + '' + + '
AB
C
' + ), + { + contentWidth: 300, + getStyleForCell: (cell) => cell.x === 0 + ? { borderRightWidth: 7, borderLeftWidth: 2 } + : { + borderRightWidth: cell.y === 0 ? 4 : 9, + borderRightColor: cell.y === 0 ? 'blue' : 'red', + borderLeftWidth: 3, + borderBottomWidth: 5 + } + } + ); + expect(layout.tableBorderStyle).toMatchObject({ + borderRightWidth: 7, + borderLeftWidth: 3, + borderBottomWidth: 5 + }); + expect(layout.cellStyles.get(layout.cells[0]!.tnode)!.borderStyle).toMatchObject({ + borderLeftWidth: 9, + borderLeftColor: 'red', + borderRightWidth: 0 + }); + expect(layout.cellStyles.get(layout.cells[1]!.tnode)!.borderStyle).toMatchObject({ + borderLeftWidth: 0, + borderRightWidth: 0, + borderBottomWidth: 5 + }); + }); +}); diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/cellStyles.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/cellStyles.test.ts index e1255e3..d8f5073 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/cellStyles.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/cellStyles.test.ts @@ -370,7 +370,7 @@ describe('cell styles', () => { maxY: 3, tableBorderStyle: FRAMED, neighbours: { - Right: [ + End: [ { x: 2, y: 1, diff --git a/packages/heuristic-table-plugin/src/helpers/__tests__/indexCellNeighbours.test.ts b/packages/heuristic-table-plugin/src/helpers/__tests__/indexCellNeighbours.test.ts index b47c81e..3bb79fa 100644 --- a/packages/heuristic-table-plugin/src/helpers/__tests__/indexCellNeighbours.test.ts +++ b/packages/heuristic-table-plugin/src/helpers/__tests__/indexCellNeighbours.test.ts @@ -17,7 +17,7 @@ function neighboursByScan( cell: DisplayCell ): CellNeighbours { return { - Right: cells.filter( + End: cells.filter( (other) => other.x === cell.x + cell.lenX && other.y < cell.y + cell.lenY && diff --git a/packages/heuristic-table-plugin/src/helpers/collapseBorders.ts b/packages/heuristic-table-plugin/src/helpers/collapseBorders.ts index 54bceb0..5e47bfb 100644 --- a/packages/heuristic-table-plugin/src/helpers/collapseBorders.ts +++ b/packages/heuristic-table-plugin/src/helpers/collapseBorders.ts @@ -112,25 +112,27 @@ type CollapsibleMatrix = { function isAtOuterEdge( cell: Pick, side: BorderSide, - { maxX, maxY }: Pick + { maxX, maxY }: Pick, + rtl: boolean ): boolean { switch (side) { case 'Top': return cell.y === 0; case 'Right': - return cell.x + cell.lenX - 1 >= maxX; + return rtl ? cell.x === 0 : cell.x + cell.lenX - 1 >= maxX; case 'Bottom': return cell.y + cell.lenY - 1 >= maxY; case 'Left': - return cell.x === 0; + return rtl ? cell.x + cell.lenX - 1 >= maxX : cell.x === 0; } } function cellsAtOuterEdge( { cells, maxX, maxY }: CollapsibleMatrix, - side: BorderSide + side: BorderSide, + rtl: boolean ): readonly C[] { - return cells.filter((cell) => isAtOuterEdge(cell, side, { maxX, maxY })); + return cells.filter((cell) => isAtOuterEdge(cell, side, { maxX, maxY }, rtl)); } function sourceCellStyle(cell: CollapsibleCell): ViewStyle { @@ -160,7 +162,7 @@ export function getCollapsedTableBorderStyle( const resolvedStyle: ViewStyle = clearLogicalBorders(tableStyle); let strongestStyle: BorderCandidate['style'] | null = null; for (const side of BOX_SIDES) { - const winner = cellsAtOuterEdge(matrix, side).reduce( + const winner = cellsAtOuterEdge(matrix, side, isRTL(tableStyle)).reduce( (currentWinner, cell) => resolveBorderConflict( currentWinner, @@ -193,6 +195,8 @@ export function getCollapsedTableBorderStyle( * bare has to stay with the cell. */ export interface CollapsedCellEdges { + /** Grid direction belongs to the table, independently of cell text direction. */ + tableRTL?: boolean; maxX: number; maxY: number; tableBorderStyle: ViewStyle | null; @@ -239,12 +243,13 @@ export function getCollapsedCellBorderStyle( maxY, tableBorderStyle, neighbours, - getCellStyle + getCellStyle, + tableRTL: rtl = isRTL(cellStyle) }: CollapsedCellEdges ): ViewStyle { const resolvedStyle: ViewStyle = clearLogicalBorders(cellStyle); const isOuterEdge = (side: BorderSide) => - isAtOuterEdge(cell, side, { maxX, maxY }); + isAtOuterEdge(cell, side, { maxX, maxY }, rtl); const isPaintedByTable = (side: BorderSide) => { const width = tableBorderStyle?.[`border${side}Width`]; return typeof width === 'number' && width > 0; @@ -273,16 +278,18 @@ export function getCollapsedCellBorderStyle( // A leading boundary is always drawn by the neighbour that precedes it, // except on the outside where there is no neighbour to draw it. paint('Top', isOuterEdge('Top') ? keepOuterBorder('Top') : null); - paint('Left', isOuterEdge('Left') ? keepOuterBorder('Left') : null); - for (const [side, opposite] of [ - ['Right', 'Left'], - ['Bottom', 'Top'] + const start = rtl ? 'Right' : 'Left'; + const end = rtl ? 'Left' : 'Right'; + paint(start, isOuterEdge(start) ? keepOuterBorder(start) : null); + for (const [side, opposite, neighbourEdge] of [ + [end, start, 'End'], + ['Bottom', 'Top', 'Bottom'] ] as const) { paint( side, isOuterEdge(side) ? keepOuterBorder(side) - : (neighbours?.[side] ?? []).reduce( + : (neighbours?.[neighbourEdge] ?? []).reduce( (winner, neighbour) => resolveBorderConflict( winner, diff --git a/packages/heuristic-table-plugin/src/helpers/indexCellNeighbours.ts b/packages/heuristic-table-plugin/src/helpers/indexCellNeighbours.ts index 6447a0e..16f3abf 100644 --- a/packages/heuristic-table-plugin/src/helpers/indexCellNeighbours.ts +++ b/packages/heuristic-table-plugin/src/helpers/indexCellNeighbours.ts @@ -2,7 +2,8 @@ import { DisplayCell } from '../shared-types'; type Cell = Pick; export interface CellNeighbours { - Right: readonly Cell[]; + /** Neighbours at increasing x: physically left in RTL, right in LTR. */ + End: readonly Cell[]; Bottom: readonly Cell[]; } @@ -61,14 +62,14 @@ function overlapping( export default function indexCellNeighbours( cells: readonly Cell[] ): ReadonlyMap { - const leftEdges = indexEdges(cells, false); + const startEdges = indexEdges(cells, false); const topEdges = indexEdges(cells, true); return new Map( cells.map((cell) => [ cell, { - Right: overlapping( - leftEdges.get(cell.x + cell.lenX), + End: overlapping( + startEdges.get(cell.x + cell.lenX), cell.y, cell.y + cell.lenY ), diff --git a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts index de0f581..a9b1ce7 100644 --- a/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts +++ b/packages/heuristic-table-plugin/src/helpers/resolveTableStyles.ts @@ -5,6 +5,7 @@ import { getSourceBlockStyle, resolveConfiguredCellStyle } from './cellPadding'; import { getCollapsedCellBorderStyle, getCollapsedTableBorderStyle } from './collapseBorders'; import composeCellStyle from './cellPadding'; import indexCellNeighbours from './indexCellNeighbours'; +import { isRTL } from './boxSides'; /** One saved style resolution shared by measurement and rendering. */ export interface ResolvedCellStyle { @@ -39,6 +40,7 @@ export default function resolveTableStyles( for (const cell of grid.cells) { const borderStyle = collapse ? getCollapsedCellBorderStyle(cell, getCellStyle(cell), { + tableRTL: isRTL(tableStyle), maxX: grid.maxX, maxY: grid.maxY, tableBorderStyle, From 2b05ff35d46b7b3a0a1f5dd12f89cae33d7c988b Mon Sep 17 00:00:00 2001 From: 5ZYSZ3K Date: Tue, 15 Sep 2026 22:12:44 +0200 Subject: [PATCH 21/21] feat(heuristic-table-plugin): add comparison example to the app --- example/App.js | 50 +- example/ComparisonExample.js | 2152 ++++++++++++++++++++++++++++++ example/HeuristicTableExample.js | 11 +- example/tsconfig.json | 4 + 4 files changed, 2206 insertions(+), 11 deletions(-) create mode 100644 example/ComparisonExample.js create mode 100644 example/tsconfig.json diff --git a/example/App.js b/example/App.js index e95290f..e289427 100644 --- a/example/App.js +++ b/example/App.js @@ -1,4 +1,3 @@ -/* eslint-disable react-native/no-inline-styles */ import React, { useCallback, useState } from 'react'; import { StatusBar } from 'expo-status-bar'; import * as WebBrowser from 'expo-web-browser'; @@ -7,7 +6,6 @@ import { Text, View, ScrollView, - UIManager, Platform, Button, useWindowDimensions @@ -23,6 +21,7 @@ import SimpleExample from './SimpleExample'; import CustomExample from './CustomExample'; import YoutubeExample from './YoutubeExample'; import HeuristicTableExample from './HeuristicTableExample'; +import ComparisonExample from './ComparisonExample'; const Stack = createStackNavigator(); @@ -147,6 +146,30 @@ function HeuristicTableScreen({ availableWidth, onLinkPress }) { ); } +function ComparisonScreen({ availableWidth, onLinkPress }) { + const [instance, setInstance] = useState(0); + return ( + + + The same HTML source rendered twice: once with{' '} + @native-html/table-plugin (WebView based) and + once with @native-html/heuristic-table-plugin{' '} + (pure native). + +