Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f005d9e
fix: adjust layout-calculating heuristics
5ZYSZ3K Aug 12, 2026
ff5b21e
fix(heuristic-table-plugin): add colgrups and redistribute widths
5ZYSZ3K Sep 3, 2026
3b0e359
feat(heuristic-table-plugin): make heuristic table respect external p…
5ZYSZ3K Sep 3, 2026
2eb8ec5
feat(heuristic-table-plugin): enable configuring font coeffs for tables
5ZYSZ3K Sep 8, 2026
4237ad3
feat(heuristic-table-plugin): implement border collapsing and stick t…
5ZYSZ3K Sep 9, 2026
6c3b7ba
feat(heuristic-table-plugin): add default padding
5ZYSZ3K Sep 10, 2026
c9c70e9
feat(heuristic-table-plugin): resolve cell styles once and lay out ag…
5ZYSZ3K Sep 15, 2026
7359ad5
fix(heuristic-table-plugin): assess tests and adjust nested table ren…
5ZYSZ3K Sep 15, 2026
7247c6a
feat(heuristic-table-plugin): hide height growts behind a prop
5ZYSZ3K Sep 15, 2026
b09711d
fix(heuristic-table-plugin): horizontal and vertical paddings adjuste…
5ZYSZ3K Sep 15, 2026
b506394
fix(heuristic-table-plugin): adjust tests
5ZYSZ3K Sep 15, 2026
da969db
fix(heuristic-table-plugin): width resolution, surplus sharing and lo…
5ZYSZ3K Sep 15, 2026
a70870e
fix(heuristic-table-plugin): fill table height, preserve row heights,…
5ZYSZ3K Sep 15, 2026
2bdeca0
fix(heuristic-table-plugin): scroll fixed-height tables without clipp…
5ZYSZ3K Sep 15, 2026
322073e
fix(heuristic-table-plugin): stop requiring contentWidth in config an…
5ZYSZ3K Sep 15, 2026
fb65684
refactor(heuristic-table-plugin): share layout helpers and drop the r…
5ZYSZ3K Sep 15, 2026
c048d04
fix(heuristic-table-plugin): render stray cells without crashing and …
5ZYSZ3K Sep 15, 2026
621678e
refactor(heuristic-table-plugin): separate the table grid from config…
5ZYSZ3K Sep 15, 2026
779d3b4
refactor(heuristic-table-plugin): split oversized modules and name th…
5ZYSZ3K Sep 15, 2026
6e6257b
fix(heuristic-table-plugin): respect table direction when collapsing …
5ZYSZ3K Sep 15, 2026
2b05ff3
feat(heuristic-table-plugin): add comparison example to the app
5ZYSZ3K Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(heuristic-table-plugin): add colgrups and redistribute widths
  • Loading branch information
5ZYSZ3K committed Sep 3, 2026
commit ff5b21e50df5a6cf0533533d180abb826f983508
6 changes: 5 additions & 1 deletion packages/heuristic-table-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<table>
Expand All @@ -78,6 +78,10 @@ const htmlProps = {
renderers: {
...tableRenderers
},
customHTMLElementModels: {
// Required for widths declared by <colgroup> and <col>.
colgroup: colgroupModel
},
renderersProps: {
table: {
// Put the table config here
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) &gt; [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>
```
11 changes: 11 additions & 0 deletions packages/heuristic-table-plugin/docs/heuristic-table-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,17 @@ Description
</th></tr></thead>
<tbody><tr><td>

[colgroupModel](./heuristic-table-plugin.colgroupmodel.md)


</td><td>

Element model required for colgroup children to be available to the table layout engine. Col elements remain non-rendering empty nodes.


</td></tr>
<tr><td>

[HTMLTable](./heuristic-table-plugin.htmltable.md)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions packages/heuristic-table-plugin/src/ColgroupModel.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 11 additions & 1 deletion packages/heuristic-table-plugin/src/HTMLTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,7 +23,7 @@ function Container({
tableWidth: number;
availableWidth: number;
}>) {
const scroll = tableWidth > availableWidth;
const scroll = shouldScrollTable(tableWidth, availableWidth);
return scroll
? React.createElement(
ScrollView,
Expand Down
21 changes: 18 additions & 3 deletions packages/heuristic-table-plugin/src/TableLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,34 @@ 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;
public readonly columnWidths: number[];
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,81 +48,28 @@ function getInitCellStatsForTnode(tnode: TNode): TCellStats {
};
}

const getMaxWordSize = pipe(
map<string, number>(prop('length')),
reduce<number, number>(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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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('<td>Medium-High</td>');

// 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('<td>Medium&#8209;High</td>');

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
Expand Down
Loading