Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-08-11

Layout-correctness release for real-world email/CMS HTML. Existing table
layouts may shift — toward what a browser renders.

### Added

- `pt` units are now parsed everywhere a length is accepted (`font-size`,
`line-height`, margins, paddings, borders, dimensions…) at the CSS ratio
1pt = 4⁄3px. Email and CMS editors (TinyMCE, Google Docs exports) emit `pt`
sizes almost exclusively; previously those declarations were silently
dropped and fell back to defaults.

### Fixed

- `height` on `table`/`tr`/`td`/`th` is now treated as a minimum height,
matching browser table semantics (CSS 2.1 §17.5) — the box grows to fit its
content. Previously it was applied as a hard RN height, so the common email
pattern `<td style="height: 25px; padding: 12px …">button</td>` clipped its
own label out of the visible box. An explicit `min-height` still wins when
larger. `height` on non-table elements is unchanged.
- A table cell's `width` now actually shapes the row. Cells used to be
rendered with `flex: colspan`, whose zero flex-basis overrode any declared
width — every column came out equal. Now: a row where every cell has a
percent width distributes columns proportionally to those percents (e.g.
`15% / 33% / 15%` keeps the 15:33:15 ratio, like a browser scaling
percentage columns); in mixed rows, sized cells are pinned via `flexBasis`
and widthless cells share the remainder; rows with no widths behave as
before.

## [0.3.0] - 2026-07-15

### Added
Expand Down
11 changes: 9 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,18 @@ tagsStyles={{

## Supported CSS

- Typography: `color`, `font-size` (px), `font-family`, `font-weight`, `font-style`, `text-align`, `text-decoration`, `text-transform`, `letter-spacing`, `line-height` (px)
- Typography: `color`, `font-size` (px/pt), `font-family`, `font-weight`, `font-style`, `text-align`, `text-decoration`, `text-transform`, `letter-spacing`, `line-height` (px/pt)
- Box model: `margin` / `padding` (shorthands + individual sides), `background-color`, the `border` family (shorthands, per-side widths/colors, `border-style`, `border-radius` including per-corner), `width`/`height` with `min-`/`max-` variants
- Other: `opacity`, `display` (`flex` and `none`)
- Colors: hex, `rgb()`, `rgba()`, `hsl()`, named (passed through to RN's color system)
- Units: **`px` only** for now — `em`/`rem` are not resolved yet, and `%` works only on `width`/`height`
- Units: **`px` and `pt`** (1pt = 4⁄3px, the common case in email/CMS output) — `em`/`rem` are not resolved yet, and `%` works only on `width`/`height`

### Table semantics

Two places where browser table behavior differs from plain RN flexbox are matched for you:

- `height` on `table`/`tr`/`td`/`th` is treated as a **minimum** — the box grows past it to fit its content, like a browser table (RN alone would clip). `height` on other elements stays a hard size.
- A cell's `width` is honored in the row: when every cell in a row has a percent width, columns share the row in proportion to those percents; cells with a px or percent width in a mixed row are pinned and the widthless cells share the remainder. Rows without widths fall back to equal columns weighted by `colspan`.

## Examples

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nikpnevmatikos/html-renderer",
"version": "0.3.0",
"version": "0.4.0",
"description": "React Native HTML renderer in TypeScript — zero native modules, Fabric/Expo compatible. Supports tagsStyles, stylesheet with CSS selectors, custom renderers, and more.",
"author": "NikPnevmatikos",
"license": "MIT",
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/render-tree/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,41 @@ describe('buildRenderTree', () => {
expect(td.colSpan).toBe(2);
});

it('treats height on table boxes as min-height (browser table semantics)', () => {
const tree = build(
'<table style="height: 50px"><tr style="height: 25px"><td style="height: 25px">a</td><th style="height: 25px">b</th></tr></table>',
);
const table = tree[0] as RenderElement;
const tr = table.children[0] as RenderElement;
const td = tr.children[0] as RenderElement;
const th = tr.children[1] as RenderElement;
for (const el of [table, tr, td, th]) {
expect(el.style.height).toBeUndefined();
}
expect(table.style.minHeight).toBe(50);
expect(tr.style.minHeight).toBe(25);
expect(td.style.minHeight).toBe(25);
expect(th.style.minHeight).toBe(25);
});

it('merges cell height with an explicit min-height by taking the max', () => {
const tree = build(
'<table><tr><td style="height: 25px; min-height: 40px">a</td><td style="height: 50px; min-height: 20px">b</td></tr></table>',
);
const tr = (tree[0] as RenderElement).children[0] as RenderElement;
const tdA = tr.children[0] as RenderElement;
const tdB = tr.children[1] as RenderElement;
expect(tdA.style.minHeight).toBe(40);
expect(tdB.style.minHeight).toBe(50);
});

it('keeps height as a hard cap on non-table elements', () => {
const tree = build('<div style="height: 25px">a</div>');
const div = tree[0] as RenderElement;
expect(div.style.height).toBe(25);
expect(div.style.minHeight).toBeUndefined();
});

it('collapses whitespace as part of the pipeline', () => {
const tree = build('<p> hello world </p>');
const p = tree[0] as RenderElement;
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/render-tree/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ const DEFAULT_IGNORED_DOM_TAGS = [
'base',
];

// Browsers treat `height` on table boxes as a minimum — rows and cells grow
// past it to fit their content (CSS 2.1 §17.5). RN applies `height` as a hard
// cap, which makes email-style `<td style="height: 25px">` clip its own
// content, so for these tags the resolved height is remapped to minHeight.
const TABLE_HEIGHT_AS_MIN_TAGS = new Set(['table', 'tr', 'td', 'th']);

function applyTableHeightSemantics(style: ResolvedStyle): void {
const h = style.height;
if (h === undefined) return;
delete style.height;
const min = style.minHeight;
if (min === undefined) {
style.minHeight = h;
} else if (typeof min === 'number' && typeof h === 'number') {
style.minHeight = Math.max(min, h);
}
}

const warnedTags = new Set<string>();

function warnUnsupportedTag(tag: string): void {
Expand Down Expand Up @@ -292,6 +310,9 @@ function buildElement(
(BLOCK_TAGS.has(el.name) ? 'block' : 'inline');

const resolved = resolveStyles(el, inherited, ctx, tagDefault, elInfo, ancestors);
if (TABLE_HEIGHT_AS_MIN_TAGS.has(el.name)) {
applyTableHeightSemantics(resolved);
}
const childPreserve = preserveWhitespace || el.name === 'pre';

const isVoid = customModel?.isVoid === true;
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/renderer/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type TextProps,
type TextStyle,
type ViewProps,
type ViewStyle,
} from 'react-native';
import type {
DomNode,
Expand All @@ -24,6 +25,7 @@ import { buildRenderTree, treeContainsTag } from '../render-tree/build';
import { resolveRootStyle } from '../styles/root';
import { splitStyle } from '../styles/split';
import { RenderedImage } from './RenderedImage';
import { resolveRowCellFlex, type CellFlexStyle } from './table-layout';

export interface CustomRendererInfo {
renderersProps: Record<string, Record<string, unknown>>;
Expand Down Expand Up @@ -425,9 +427,12 @@ function renderTableRow(
cells.push(c);
}
}
const cellFlex = resolveRowCellFlex(
cells.map((c) => ({ width: c.style.width, colSpan: c.colSpan })),
);
return (
<View key={key} {...ctx.defaultViewProps} style={styles.tr}>
{cells.map((cell, i) => renderTableCell(cell, i, ctx))}
{cells.map((cell, i) => renderTableCell(cell, i, ctx, cellFlex[i]))}
</View>
);
}
Expand All @@ -436,14 +441,20 @@ function renderTableCell(
cell: RenderElement,
key: React.Key | undefined,
ctx: RenderCtx,
flexStyle?: CellFlexStyle,
): React.ReactNode {
const { view: vStyle } = splitStyle(cell.style);
const flex = cell.colSpan ?? 1;
if (flexStyle) {
// The row layout consumed the cell's width (as a proportion or as
// flexBasis) — leaving it here too would fight the flex rule.
delete vStyle.width;
}
const flexRule = (flexStyle ?? { flex: cell.colSpan ?? 1 }) as ViewStyle;
return (
<View
key={key}
{...ctx.defaultViewProps}
style={[styles.tableCell, { flex }, vStyle]}
style={[styles.tableCell, flexRule, vStyle]}
>
{renderBlockChildren(cell.children, cell.style, ctx)}
</View>
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/renderer/table-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { resolveRowCellFlex } from './table-layout';

describe('resolveRowCellFlex', () => {
it('keeps equal flexible columns when no cell has a width', () => {
expect(resolveRowCellFlex([{}, {}, {}])).toEqual([
{ flexGrow: 1, flexShrink: 1, flexBasis: 0 },
{ flexGrow: 1, flexShrink: 1, flexBasis: 0 },
{ flexGrow: 1, flexShrink: 1, flexBasis: 0 },
]);
});

it('weights widthless columns by colSpan', () => {
expect(resolveRowCellFlex([{ colSpan: 2 }, {}])).toEqual([
{ flexGrow: 2, flexShrink: 1, flexBasis: 0 },
{ flexGrow: 1, flexShrink: 1, flexBasis: 0 },
]);
});

it('shares the row proportionally when every cell has a percent width', () => {
expect(
resolveRowCellFlex([
{ width: '15%' },
{ width: '33.0621%' },
{ width: '15%' },
]),
).toEqual([
{ flexGrow: 15, flexShrink: 1, flexBasis: 0 },
{ flexGrow: 33.0621, flexShrink: 1, flexBasis: 0 },
{ flexGrow: 15, flexShrink: 1, flexBasis: 0 },
]);
});

it('pins sized cells and lets the rest share the remainder', () => {
expect(
resolveRowCellFlex([{ width: '50%' }, {}, { width: 120 }]),
).toEqual([
{ flexGrow: 0, flexShrink: 1, flexBasis: '50%' },
{ flexGrow: 1, flexShrink: 1, flexBasis: 0 },
{ flexGrow: 0, flexShrink: 1, flexBasis: 120 },
]);
});

it('treats a non-positive percent as a pinned width, not a proportion', () => {
expect(resolveRowCellFlex([{ width: '0%' }, { width: '100%' }])).toEqual([
{ flexGrow: 0, flexShrink: 1, flexBasis: '0%' },
{ flexGrow: 0, flexShrink: 1, flexBasis: '100%' },
]);
});

it('returns empty for an empty row', () => {
expect(resolveRowCellFlex([])).toEqual([]);
});
});
39 changes: 39 additions & 0 deletions packages/core/src/renderer/table-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface CellLayoutInput {
width?: number | string;
colSpan?: number;
}

export interface CellFlexStyle {
flexGrow: number;
flexShrink: number;
flexBasis: number | string;
}

function percentValue(width: number | string | undefined): number | null {
if (typeof width !== 'string' || !width.trim().endsWith('%')) return null;
const n = parseFloat(width);
return Number.isFinite(n) && n > 0 ? n : null;
}

/**
* Flex layout for the cells of one table row.
*
* Cells default to equal flexible columns (weighted by colSpan), but an
* explicit `width` on a cell must beat that default:
* - every cell has a percent width → columns share the row in proportion to
* those percentages (like browsers scaling percentage columns to fill the
* table, e.g. 15%/33%/15% keeps the 15:33:15 ratio);
* - otherwise cells with a width (percent or px) are pinned via flexBasis and
* the remaining cells share the leftover space.
*/
export function resolveRowCellFlex(cells: CellLayoutInput[]): CellFlexStyle[] {
const percents = cells.map((c) => percentValue(c.width));
if (cells.length > 0 && percents.every((p) => p !== null)) {
return percents.map((p) => ({ flexGrow: p!, flexShrink: 1, flexBasis: 0 }));
}
return cells.map((c) =>
c.width !== undefined
? { flexGrow: 0, flexShrink: 1, flexBasis: c.width }
: { flexGrow: c.colSpan ?? 1, flexShrink: 1, flexBasis: 0 },
);
}
13 changes: 13 additions & 0 deletions packages/core/src/styles/parse-inline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ describe('parseInlineStyle', () => {
expect(parseInlineStyle('font-size: 12')).toEqual({ fontSize: 12 });
});

it('parses pt units at 4/3 px (common in email/CMS HTML)', () => {
expect(parseInlineStyle('font-size: 12pt')).toEqual({ fontSize: 16 });
expect(parseInlineStyle('font-size: 16pt').fontSize).toBeCloseTo(21.333, 2);
expect(parseInlineStyle('line-height: 12pt')).toEqual({ lineHeight: 16 });
expect(parseInlineStyle('margin: 6pt')).toEqual({
marginTop: 8,
marginRight: 8,
marginBottom: 8,
marginLeft: 8,
});
expect(parseInlineStyle('height: 15pt')).toEqual({ height: 20 });
});

it('parses font-weight keyword and numeric', () => {
expect(parseInlineStyle('font-weight: bold')).toEqual({
fontWeight: 'bold',
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/styles/parse-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,13 +467,16 @@ function stripFontFamilyQuotes(value: string): string {
const EM_BASE_PX = 14;

function parsePx(value: string): number | null {
const match = /^(-?\d+(?:\.\d+)?)\s*(px|em|rem)?$/.exec(value);
const match = /^(-?\d+(?:\.\d+)?)\s*(px|em|rem|pt)?$/.exec(value);
if (!match) return null;
const n = parseFloat(match[1]!);
const unit = match[2];
if (unit === 'em' || unit === 'rem') {
return n * EM_BASE_PX;
}
if (unit === 'pt') {
return (n * 4) / 3;
}
return n;
}

Expand Down
Loading