diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b8d815..9bb9d53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
button | ` 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
diff --git a/packages/core/README.md b/packages/core/README.md
index a945672..b701834 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -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
diff --git a/packages/core/package.json b/packages/core/package.json
index 48c7138..da88712 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -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",
diff --git a/packages/core/src/render-tree/build.test.ts b/packages/core/src/render-tree/build.test.ts
index 5181846..b779013 100644
--- a/packages/core/src/render-tree/build.test.ts
+++ b/packages/core/src/render-tree/build.test.ts
@@ -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(
+ '',
+ );
+ 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(
+ '',
+ );
+ 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('a
');
+ 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(' hello world
');
const p = tree[0] as RenderElement;
diff --git a/packages/core/src/render-tree/build.ts b/packages/core/src/render-tree/build.ts
index ac8f8f1..4b1a894 100644
--- a/packages/core/src/render-tree/build.ts
+++ b/packages/core/src/render-tree/build.ts
@@ -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 `` 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();
function warnUnsupportedTag(tag: string): void {
@@ -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;
diff --git a/packages/core/src/renderer/Renderer.tsx b/packages/core/src/renderer/Renderer.tsx
index 1a15331..8051ac5 100644
--- a/packages/core/src/renderer/Renderer.tsx
+++ b/packages/core/src/renderer/Renderer.tsx
@@ -9,6 +9,7 @@ import {
type TextProps,
type TextStyle,
type ViewProps,
+ type ViewStyle,
} from 'react-native';
import type {
DomNode,
@@ -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>;
@@ -425,9 +427,12 @@ function renderTableRow(
cells.push(c);
}
}
+ const cellFlex = resolveRowCellFlex(
+ cells.map((c) => ({ width: c.style.width, colSpan: c.colSpan })),
+ );
return (
- {cells.map((cell, i) => renderTableCell(cell, i, ctx))}
+ {cells.map((cell, i) => renderTableCell(cell, i, ctx, cellFlex[i]))}
);
}
@@ -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 (
{renderBlockChildren(cell.children, cell.style, ctx)}
diff --git a/packages/core/src/renderer/table-layout.test.ts b/packages/core/src/renderer/table-layout.test.ts
new file mode 100644
index 0000000..89bbf3a
--- /dev/null
+++ b/packages/core/src/renderer/table-layout.test.ts
@@ -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([]);
+ });
+});
diff --git a/packages/core/src/renderer/table-layout.ts b/packages/core/src/renderer/table-layout.ts
new file mode 100644
index 0000000..b1e05f8
--- /dev/null
+++ b/packages/core/src/renderer/table-layout.ts
@@ -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 },
+ );
+}
diff --git a/packages/core/src/styles/parse-inline.test.ts b/packages/core/src/styles/parse-inline.test.ts
index 1fd557f..31f7b79 100644
--- a/packages/core/src/styles/parse-inline.test.ts
+++ b/packages/core/src/styles/parse-inline.test.ts
@@ -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',
diff --git a/packages/core/src/styles/parse-inline.ts b/packages/core/src/styles/parse-inline.ts
index 4c87579..9739988 100644
--- a/packages/core/src/styles/parse-inline.ts
+++ b/packages/core/src/styles/parse-inline.ts
@@ -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;
}
|