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( + '
ab
', + ); + 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( + '
ab
', + ); + 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; }